feat(cli/daemon/processmonitor): Add flexible target resolution and search command; improve restart/backoff and error handling

This commit is contained in:
2025-08-30 16:55:10 +00:00
parent 22a43204d4
commit ebc20a9232
17 changed files with 327 additions and 109 deletions

View File

@@ -1,5 +1,17 @@
# Changelog # Changelog
## 2025-08-30 - 5.3.0 - feat(cli/daemon/processmonitor)
Add flexible target resolution and search command; improve restart/backoff and error handling
- Add new cli command `search` to find processes by id or name fragment.
- Allow flexible process targets in CLI commands (accepts numeric id, id:<n>, or name:<label>) for start/stop/restart/delete/describe/logs/edit commands.
- Introduce a new daemon IPC method `resolveTarget` to normalize user-provided targets to ProcessId (supports id:<n>, name:<label>, or bare numeric id).
- Keep `remove` as a CLI alias but daemon exposes `delete` only; CLI resolves targets and always calls daemon `delete`.
- Implement scheduled restart/backoff in ProcessMonitor with incremental debounce, max retries, and a 1-hour reset window.
- Emit a `failed` event from ProcessMonitor when max restart attempts are exceeded; ProcessManager listens and marks processes as `errored` and clears pid.
- Ensure desired state is set to `stopped` before deleting a process to avoid race conditions.
- Improve cli output messages to include resolved names alongside numeric ids where available.
## 2025-08-30 - 5.2.0 - feat(cli) ## 2025-08-30 - 5.2.0 - feat(cli)
Preserve CLI environment when adding processes, simplify edit flow, and refresh README docs Preserve CLI environment when adding processes, simplify edit flow, and refresh README docs

View File

@@ -38,21 +38,23 @@ npm install --save-dev @git.zone/tspm
# Add a process (creates config without starting) # Add a process (creates config without starting)
tspm add "node server.js" --name my-server --memory 1GB tspm add "node server.js" --name my-server --memory 1GB
# Start the process # Start the process (by name or id)
tspm start my-server tspm start name:my-server
# or
tspm start id:1
# Or add and start in one go # Or add and start in one go
tspm add "node app.js" --name my-app tspm add "node app.js" --name my-app
tspm start my-app tspm start name:my-app
# List all processes # List all processes
tspm list tspm list
# View logs # View logs
tspm logs my-app tspm logs name:my-app
# Stop a process # Stop a process
tspm stop my-app tspm stop name:my-app
``` ```
## 📋 Commands ## 📋 Commands
@@ -86,38 +88,38 @@ tspm add "tsx watch src/index.ts" --name dev-server --watch --watch-paths "src,c
tspm add "node worker.js" --name one-time-job --autorestart false tspm add "node worker.js" --name one-time-job --autorestart false
``` ```
#### `tspm start <id>` #### `tspm start <id|id:N|name:LABEL>`
Start a previously added process by its ID or name. Start a previously added process by its ID or name.
```bash ```bash
tspm start my-server tspm start name:my-server
tspm start 1 # Can also use numeric ID tspm start id:1 # Or a bare numeric id: tspm start 1
``` ```
#### `tspm stop <id>` #### `tspm stop <id|id:N|name:LABEL>`
Gracefully stop a running process (SIGTERM → SIGKILL after timeout). Gracefully stop a running process (SIGTERM → SIGKILL after timeout).
```bash ```bash
tspm stop my-server tspm stop name:my-server
``` ```
#### `tspm restart <id>` #### `tspm restart <id|id:N|name:LABEL>`
Stop and restart a process with the same configuration. Stop and restart a process with the same configuration.
```bash ```bash
tspm restart my-server tspm restart name:my-server
``` ```
#### `tspm delete <id>` / `tspm remove <id>` #### `tspm delete <id|id:N|name:LABEL>` / `tspm remove <id|id:N|name:LABEL>`
Stop and remove a process from TSPM management. Also deletes persisted logs. Stop and remove a process from TSPM management. Also deletes persisted logs.
```bash ```bash
tspm delete old-server tspm delete name:old-server
tspm remove old-server # Alias for delete tspm remove name:old-server # Alias for delete (daemon handles delete)
``` ```
#### `tspm edit <id>` #### `tspm edit <id>`
@@ -148,12 +150,12 @@ tspm list
└─────────┴─────────────┴───────────┴───────────┴──────────┴──────────┘ └─────────┴─────────────┴───────────┴───────────┴──────────┴──────────┘
``` ```
#### `tspm describe <id>` #### `tspm describe <id|id:N|name:LABEL>`
Get detailed information about a specific process. Get detailed information about a specific process.
```bash ```bash
tspm describe my-server tspm describe name:my-server
# Output: # Output:
Process Details: my-server Process Details: my-server
@@ -173,7 +175,7 @@ Auto-restart: true
Watch: disabled Watch: disabled
``` ```
#### `tspm logs <id> [options]` #### `tspm logs <id|id:N|name:LABEL> [options]`
View process logs (stdout and stderr combined). View process logs (stdout and stderr combined).
@@ -183,13 +185,13 @@ View process logs (stdout and stderr combined).
```bash ```bash
# View last 50 lines # View last 50 lines
tspm logs my-server tspm logs name:my-server
# View last 100 lines # View last 100 lines
tspm logs my-server --lines 100 tspm logs name:my-server --lines 100
# Follow logs in real-time # Follow logs in real-time
tspm logs my-server --follow tspm logs name:my-server --follow
``` ```
### Batch Operations ### Batch Operations
@@ -496,7 +498,31 @@ Common issues:
- **"Daemon not running"**: Run `tspm daemon start` or `tspm enable` - **"Daemon not running"**: Run `tspm daemon start` or `tspm enable`
- **"Permission denied"**: Check socket permissions in `~/.tspm/` - **"Permission denied"**: Check socket permissions in `~/.tspm/`
- **"Process won't start"**: Check logs with `tspm logs <id>` - **"Process won't start"**: Check logs with `tspm logs <id|id:N|name:LABEL>`
## 🎯 Targeting Processes (IDs and Names)
Most process commands accept the following target formats:
- Numeric ID: `tspm start 1`
- Explicit ID: `tspm start id:1`
- Explicit name: `tspm start name:api-server`
Notes:
- Names must be used with the `name:` prefix.
- If multiple processes share the same name, the CLI will report the ambiguous matches. Use `id:N` to disambiguate.
- Use `tspm search <query>` to discover IDs by name or ID fragments.
### `tspm search <query>`
Search processes by name or ID substring and print matching IDs (and names when available):
```bash
tspm search api
# Matches for "api":
# - id:3 name:api-server
```
- **"Memory limit exceeded"**: Increase limit with `tspm edit <id>` - **"Memory limit exceeded"**: Increase limit with `tspm edit <id>`
## 🤝 Why Choose TSPM? ## 🤝 Why Choose TSPM?
@@ -515,6 +541,20 @@ Common issues:
### Perfect For ### Perfect For
### Restart Backoff and Failure Handling
TSPM automatically restarts crashed processes with an incremental backoff:
- Debounce delay grows linearly from 1s up to 10s for consecutive retries.
- After the 10th retry, the process is marked as failed (status: "errored") and auto-restarts stop.
- The retry counter resets if no retry happens for 1 hour since the last attempt.
You can manually restart a failed process at any time:
```bash
tspm restart id:1
```
- 🚀 **Production Node.js apps** - Reliable process management - 🚀 **Production Node.js apps** - Reliable process management
- 🔧 **Microservices** - Manage multiple services easily - 🔧 **Microservices** - Manage multiple services easily
- 👨💻 **Development** - File watching and auto-restart - 👨💻 **Development** - File watching and auto-restart

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@git.zone/tspm', name: '@git.zone/tspm',
version: '5.2.0', version: '5.3.0',
description: 'a no fuzz process manager' description: 'a no fuzz process manager'
} }

View File

@@ -22,13 +22,14 @@ export function registerDefaultCommand(smartcli: plugins.smartcli.Smartcli) {
); );
console.log(' disable Disable TSPM system service'); console.log(' disable Disable TSPM system service');
console.log('\nProcess Commands:'); console.log('\nProcess Commands:');
console.log(' start <script> Start a process'); console.log(' start <id|id:N|name:LBL> Start a process');
console.log(' list List all processes'); console.log(' list List all processes');
console.log(' stop <id> Stop a process'); console.log(' stop <id|id:N|name:LBL> Stop a process');
console.log(' restart <id> Restart a process'); console.log(' restart <id|id:N|name:LBL> Restart a process');
console.log(' delete <id> Delete a process'); console.log(' delete <id|id:N|name:LBL> Delete a process');
console.log(' describe <id> Show details for a process'); console.log(' describe <id|id:N|name:LBL> Show details for a process');
console.log(' logs <id> Show logs for a process'); console.log(' logs <id|id:N|name:LBL> Show logs for a process');
console.log(' search <query> Find processes by id/name');
console.log(' start-all Start all saved processes'); console.log(' start-all Start all saved processes');
console.log(' stop-all Stop all processes'); console.log(' stop-all Stop all processes');
console.log(' restart-all Restart all processes'); console.log(' restart-all Restart all processes');

View File

@@ -8,23 +8,25 @@ export function registerDeleteCommand(smartcli: plugins.smartcli.Smartcli) {
smartcli, smartcli,
['delete', 'remove'], ['delete', 'remove'],
async (argvArg: CliArguments) => { async (argvArg: CliArguments) => {
const id = argvArg._[1]; const target = argvArg._[1];
if (!id) { if (!target) {
console.error('Error: Please provide a process ID'); console.error('Error: Please provide a process target');
console.log('Usage: tspm delete <id> | tspm remove <id>'); console.log('Usage: tspm delete <id|id:N|name:LABEL> | tspm remove <id|id:N|name:LABEL>');
return; return;
} }
// Determine if command was 'remove' to use the new IPC route, otherwise 'delete' // Determine if command was 'remove' to use the new IPC route, otherwise 'delete'
const cmd = String(argvArg._[0]); const cmd = String(argvArg._[0]);
const useRemove = cmd === 'remove'; const isRemoveAlias = cmd === 'remove';
console.log(`${useRemove ? 'Removing' : 'Deleting'} process: ${id}`); console.log(`${isRemoveAlias ? 'Removing' : 'Deleting'} process: ${target}`);
const response = await tspmIpcClient.request(useRemove ? 'remove' : 'delete', { id } as any); const resolved = await tspmIpcClient.request('resolveTarget', { target: String(target) });
// Always call daemon 'delete'; 'remove' is CLI alias only
const response = await tspmIpcClient.request('delete', { id: resolved.id } as any);
if (response.success) { if (response.success) {
console.log(`${response.message || (useRemove ? 'Removed successfully' : 'Deleted successfully')}`); console.log(`${response.message || (isRemoveAlias ? 'Removed successfully' : 'Deleted successfully')}`);
} else { } else {
console.error(`✗ Failed to ${useRemove ? 'remove' : 'delete'} process: ${response.message}`); console.error(`✗ Failed to ${isRemoveAlias ? 'remove' : 'delete'} process: ${response.message}`);
} }
}, },
{ actionLabel: 'delete/remove process' }, { actionLabel: 'delete/remove process' },

View File

@@ -9,16 +9,17 @@ export function registerDescribeCommand(smartcli: plugins.smartcli.Smartcli) {
smartcli, smartcli,
'describe', 'describe',
async (argvArg: CliArguments) => { async (argvArg: CliArguments) => {
const id = argvArg._[1]; const target = argvArg._[1];
if (!id) { if (!target) {
console.error('Error: Please provide a process ID'); console.error('Error: Please provide a process target');
console.log('Usage: tspm describe <id>'); console.log('Usage: tspm describe <id | id:N | name:LABEL>');
return; return;
} }
const response = await tspmIpcClient.request('describe', { id }); const resolved = await tspmIpcClient.request('resolveTarget', { target: String(target) });
const response = await tspmIpcClient.request('describe', { id: resolved.id });
console.log(`Process Details: ${id}`); console.log(`Process Details: ${response.config.name || resolved.id}`);
console.log('─'.repeat(40)); console.log('─'.repeat(40));
console.log(`Status: ${response.processInfo.status}`); console.log(`Status: ${response.processInfo.status}`);
console.log(`PID: ${response.processInfo.pid || 'N/A'}`); console.log(`PID: ${response.processInfo.pid || 'N/A'}`);

View File

@@ -9,17 +9,16 @@ export function registerEditCommand(smartcli: plugins.smartcli.Smartcli) {
smartcli, smartcli,
'edit', 'edit',
async (argvArg: CliArguments) => { async (argvArg: CliArguments) => {
const idRaw = argvArg._[1]; const target = argvArg._[1];
if (!idRaw) { if (!target) {
console.error('Error: Please provide a process ID to edit'); console.error('Error: Please provide a process target to edit');
console.log('Usage: tspm edit <id>'); console.log('Usage: tspm edit <id | id:N | name:LABEL>');
return; return;
} }
const id = idRaw; // Resolve and load current config
const resolved = await tspmIpcClient.request('resolveTarget', { target: String(target) });
// Load current config const { config } = await tspmIpcClient.request('describe', { id: resolved.id });
const { config } = await tspmIpcClient.request('describe', { id });
// Interactive editing is temporarily disabled - needs smartinteract API update // Interactive editing is temporarily disabled - needs smartinteract API update
console.log('Interactive editing is temporarily disabled.'); console.log('Interactive editing is temporarily disabled.');
@@ -63,7 +62,7 @@ export function registerEditCommand(smartcli: plugins.smartcli.Smartcli) {
}; };
const updateResponse = await tspmIpcClient.request('update', { const updateResponse = await tspmIpcClient.request('update', {
id, id: resolved.id,
updates, updates,
}); });
@@ -73,4 +72,3 @@ export function registerEditCommand(smartcli: plugins.smartcli.Smartcli) {
{ actionLabel: 'edit process config' }, { actionLabel: 'edit process config' },
); );
} }

View File

@@ -11,10 +11,10 @@ export function registerLogsCommand(smartcli: plugins.smartcli.Smartcli) {
smartcli, smartcli,
'logs', 'logs',
async (argvArg: CliArguments) => { async (argvArg: CliArguments) => {
const id = argvArg._[1]; const target = argvArg._[1];
if (!id) { if (!target) {
console.error('Error: Please provide a process ID'); console.error('Error: Please provide a process target');
console.log('Usage: tspm logs <id> [options]'); console.log('Usage: tspm logs <id | id:N | name:LABEL> [options]');
console.log('\nOptions:'); console.log('\nOptions:');
console.log(' --lines <n> Number of lines to show (default: 50)'); console.log(' --lines <n> Number of lines to show (default: 50)');
console.log(' --follow Stream logs in real-time (like tail -f)'); console.log(' --follow Stream logs in real-time (like tail -f)');
@@ -24,6 +24,8 @@ export function registerLogsCommand(smartcli: plugins.smartcli.Smartcli) {
const lines = getNumber(argvArg, 'lines', 50); const lines = getNumber(argvArg, 'lines', 50);
const follow = getBool(argvArg, 'follow', 'f'); const follow = getBool(argvArg, 'follow', 'f');
const resolved = await tspmIpcClient.request('resolveTarget', { target: String(target) });
const id = resolved.id;
const response = await tspmIpcClient.request('getLogs', { id, lines }); const response = await tspmIpcClient.request('getLogs', { id, lines });
if (!follow) { if (!follow) {
@@ -44,7 +46,7 @@ export function registerLogsCommand(smartcli: plugins.smartcli.Smartcli) {
} }
// Streaming mode // Streaming mode
console.log(`Logs for process: ${id} (streaming...)`); console.log(`Logs for process: ${resolved.name || id} (streaming...)`);
console.log('─'.repeat(60)); console.log('─'.repeat(60));
let lastSeq = 0; let lastSeq = 0;

View File

@@ -1,6 +1,5 @@
import * as plugins from '../../plugins.js'; import * as plugins from '../../plugins.js';
import { tspmIpcClient } from '../../../client/tspm.ipcclient.js'; import { tspmIpcClient } from '../../../client/tspm.ipcclient.js';
import { toProcessId } from '../../../shared/protocol/id.js';
import type { CliArguments } from '../../types.js'; import type { CliArguments } from '../../types.js';
import { registerIpcCommand } from '../../registration/index.js'; import { registerIpcCommand } from '../../registration/index.js';
@@ -11,9 +10,9 @@ export function registerRestartCommand(smartcli: plugins.smartcli.Smartcli) {
async (argvArg: CliArguments) => { async (argvArg: CliArguments) => {
const arg = argvArg._[1]; const arg = argvArg._[1];
if (!arg) { if (!arg) {
console.error('Error: Please provide a process ID or "all"'); console.error('Error: Please provide a process target or "all"');
console.log('Usage:'); console.log('Usage:');
console.log(' tspm restart <id>'); console.log(' tspm restart <id | id:N | name:LABEL>');
console.log(' tspm restart all'); console.log(' tspm restart all');
return; return;
} }
@@ -33,12 +32,13 @@ export function registerRestartCommand(smartcli: plugins.smartcli.Smartcli) {
return; return;
} }
const id = String(arg); const target = String(arg);
console.log(`Restarting process: ${id}`); console.log(`Restarting process: ${target}`);
const response = await tspmIpcClient.request('restart', { id: toProcessId(id) }); const resolved = await tspmIpcClient.request('resolveTarget', { target });
const response = await tspmIpcClient.request('restart', { id: resolved.id });
console.log(`✓ Process restarted successfully`); console.log(`✓ Process restarted successfully`);
console.log(` ID: ${response.processId}`); console.log(` ID: ${response.processId}${resolved.name ? ` (name: ${resolved.name})` : ''}`);
console.log(` PID: ${response.pid || 'N/A'}`); console.log(` PID: ${response.pid || 'N/A'}`);
console.log(` Status: ${response.status}`); console.log(` Status: ${response.status}`);
}, },

View File

@@ -0,0 +1,62 @@
import * as plugins from '../../plugins.js';
import { tspmIpcClient } from '../../../client/tspm.ipcclient.js';
import type { CliArguments } from '../../types.js';
import { registerIpcCommand } from '../../registration/index.js';
export function registerSearchCommand(smartcli: plugins.smartcli.Smartcli) {
registerIpcCommand(
smartcli,
'search',
async (argvArg: CliArguments) => {
const query = String(argvArg._[1] || '').trim();
if (!query) {
console.error('Error: Please provide a search query');
console.log('Usage: tspm search <name-fragment | id-fragment>');
return;
}
// Fetch list of processes, then enrich with names via describe
const listRes = await tspmIpcClient.request('list', {});
const processes = listRes.processes;
// If there are no processes, short-circuit
if (processes.length === 0) {
console.log('No processes found.');
return;
}
const lowerQ = query.toLowerCase();
const matches: Array<{ id: number; name?: string }> = [];
// Collect describe calls to obtain names
for (const proc of processes) {
try {
const desc = await tspmIpcClient.request('describe', { id: proc.id });
const name = desc.config.name || '';
const idStr = String(proc.id);
if (name.toLowerCase().includes(lowerQ) || idStr.includes(query)) {
matches.push({ id: proc.id, name });
}
} catch {
// Ignore describe errors for individual processes
}
}
if (matches.length === 0) {
console.log(`No matches for "${query}"`);
return;
}
console.log(`Matches for "${query}":`);
for (const m of matches) {
if (m.name) {
console.log(`- id:${m.id}\tname:${m.name}`);
} else {
console.log(`- id:${m.id}`);
}
}
},
{ actionLabel: 'search processes' },
);
}

View File

@@ -10,17 +10,18 @@ export function registerStartCommand(smartcli: plugins.smartcli.Smartcli) {
smartcli, smartcli,
'start', 'start',
async (argvArg: CliArguments) => { async (argvArg: CliArguments) => {
const id = argvArg._[1]; const target = argvArg._[1];
if (!id) { if (!target) {
console.error('Error: Please provide a process ID to start'); console.error('Error: Please provide a process target to start');
console.log('Usage: tspm start <id>'); console.log('Usage: tspm start <id | id:N | name:LABEL>');
return; return;
} }
console.log(`Starting process id ${id}...`); console.log(`Starting process: ${target}...`);
const response = await tspmIpcClient.request('startById', { id }); const resolved = await tspmIpcClient.request('resolveTarget', { target: String(target) });
const response = await tspmIpcClient.request('startById', { id: resolved.id });
console.log('✓ Process started'); console.log('✓ Process started');
console.log(` ID: ${response.processId}`); console.log(` ID: ${response.processId}${resolved.name ? ` (name: ${resolved.name})` : ''}`);
console.log(` PID: ${response.pid || 'N/A'}`); console.log(` PID: ${response.pid || 'N/A'}`);
console.log(` Status: ${response.status}`); console.log(` Status: ${response.status}`);
}, },

View File

@@ -8,15 +8,16 @@ export function registerStopCommand(smartcli: plugins.smartcli.Smartcli) {
smartcli, smartcli,
'stop', 'stop',
async (argvArg: CliArguments) => { async (argvArg: CliArguments) => {
const id = argvArg._[1]; const target = argvArg._[1];
if (!id) { if (!target) {
console.error('Error: Please provide a process ID'); console.error('Error: Please provide a process target');
console.log('Usage: tspm stop <id>'); console.log('Usage: tspm stop <id | id:N | name:LABEL>');
return; return;
} }
console.log(`Stopping process: ${id}`); console.log(`Stopping process: ${target}`);
const response = await tspmIpcClient.request('stop', { id }); const resolved = await tspmIpcClient.request('resolveTarget', { target: String(target) });
const response = await tspmIpcClient.request('stop', { id: resolved.id });
if (response.success) { if (response.success) {
console.log(`${response.message}`); console.log(`${response.message}`);

View File

@@ -10,6 +10,7 @@ import { registerAddCommand } from './commands/process/add.js';
import { registerStopCommand } from './commands/process/stop.js'; import { registerStopCommand } from './commands/process/stop.js';
import { registerRestartCommand } from './commands/process/restart.js'; import { registerRestartCommand } from './commands/process/restart.js';
import { registerDeleteCommand } from './commands/process/delete.js'; import { registerDeleteCommand } from './commands/process/delete.js';
import { registerSearchCommand } from './commands/process/search.js';
import { registerListCommand } from './commands/process/list.js'; import { registerListCommand } from './commands/process/list.js';
import { registerDescribeCommand } from './commands/process/describe.js'; import { registerDescribeCommand } from './commands/process/describe.js';
import { registerLogsCommand } from './commands/process/logs.js'; import { registerLogsCommand } from './commands/process/logs.js';
@@ -74,6 +75,7 @@ export const run = async (): Promise<void> => {
registerDescribeCommand(smartcliInstance); registerDescribeCommand(smartcliInstance);
registerLogsCommand(smartcliInstance); registerLogsCommand(smartcliInstance);
registerEditCommand(smartcliInstance); registerEditCommand(smartcliInstance);
registerSearchCommand(smartcliInstance);
// Batch commands // Batch commands
registerStartAllCommand(smartcliInstance); registerStartAllCommand(smartcliInstance);

View File

@@ -156,6 +156,11 @@ export class ProcessManager extends EventEmitter {
this.updateProcessInfo(config.id, { pid: undefined }); this.updateProcessInfo(config.id, { pid: undefined });
}); });
// Set up failure handler to mark process as errored
monitor.on('failed', () => {
this.updateProcessInfo(config.id, { status: 'errored', pid: undefined });
});
await monitor.start(); await monitor.start();
// Wait a moment for the process to spawn and get its PID // Wait a moment for the process to spawn and get its PID
@@ -327,6 +332,11 @@ export class ProcessManager extends EventEmitter {
}); });
} }
// Mark errored on failure events
newMonitor.on('failed', () => {
this.updateProcessInfo(id, { status: 'errored', pid: undefined });
});
this.logger.info(`Successfully restarted process with id '${id}'`); this.logger.info(`Successfully restarted process with id '${id}'`);
} catch (error: Error | unknown) { } catch (error: Error | unknown) {
const processError = new ProcessError( const processError = new ProcessError(

View File

@@ -18,6 +18,10 @@ export class ProcessMonitor extends EventEmitter {
private processId?: ProcessId; private processId?: ProcessId;
private currentLogMemorySize: number = 0; private currentLogMemorySize: number = 0;
private readonly MAX_LOG_MEMORY_SIZE = 10 * 1024 * 1024; // 10MB private readonly MAX_LOG_MEMORY_SIZE = 10 * 1024 * 1024; // 10MB
private restartTimer: NodeJS.Timeout | null = null;
private lastRetryAt: number | null = null;
private readonly MAX_RETRIES = 10;
private readonly RESET_WINDOW_MS = 60 * 60 * 1000; // 1 hour
constructor(config: IMonitorConfig & { id?: ProcessId }) { constructor(config: IMonitorConfig & { id?: ProcessId }) {
super(); super();
@@ -132,10 +136,7 @@ export class ProcessMonitor extends EventEmitter {
this.emit('exit', code, signal); this.emit('exit', code, signal);
if (!this.stopped) { if (!this.stopped) {
this.logger.info('Restarting process...'); this.scheduleRestart('exit');
this.log('Restarting process...');
this.restartCount++;
this.spawnProcess();
} else { } else {
this.logger.debug( this.logger.debug(
'Not restarting process because monitor is stopped', 'Not restarting process because monitor is stopped',
@@ -164,10 +165,7 @@ export class ProcessMonitor extends EventEmitter {
} }
if (!this.stopped) { if (!this.stopped) {
this.logger.info('Restarting process due to error...'); this.scheduleRestart('error');
this.log('Restarting process due to error...');
this.restartCount++;
this.spawnProcess();
} else { } else {
this.logger.debug('Not restarting process because monitor is stopped'); this.logger.debug('Not restarting process because monitor is stopped');
} }
@@ -185,6 +183,49 @@ export class ProcessMonitor extends EventEmitter {
} }
} }
/**
* Schedule a restart with incremental debounce and failure cutoff.
*/
private scheduleRestart(reason: 'exit' | 'error'): void {
const now = Date.now();
// Reset window: if last retry was more than 1 hour ago, reset counter
if (this.lastRetryAt && now - this.lastRetryAt >= this.RESET_WINDOW_MS) {
this.logger.info('Resetting retry counter after 1 hour window');
this.restartCount = 0;
}
// Already at or above max retries?
if (this.restartCount >= this.MAX_RETRIES) {
const msg = 'Maximum restart attempts reached. Marking process as failed.';
this.logger.warn(msg);
this.log(msg);
this.stopped = true;
// Emit a specific event so manager can set status to errored
this.emit('failed');
return;
}
// Increment and compute delay (1..10 seconds)
this.restartCount++;
const delaySec = Math.min(this.restartCount, 10);
const msg = `Restarting process in ${delaySec}s (attempt ${this.restartCount}/${this.MAX_RETRIES}) due to ${reason}...`;
this.logger.info(msg);
this.log(msg);
// Clear existing timer if any, then schedule
if (this.restartTimer) {
clearTimeout(this.restartTimer);
}
this.lastRetryAt = now;
this.restartTimer = setTimeout(() => {
// If stopped in the meantime, do not spawn
if (this.stopped) {
return;
}
this.spawnProcess();
}, delaySec * 1000);
}
/** /**
* Monitor the process group's memory usage. If the total memory exceeds the limit, * Monitor the process group's memory usage. If the total memory exceeds the limit,
* kill the process group so that the 'exit' handler can restart it. * kill the process group so that the 'exit' handler can restart it.

View File

@@ -208,6 +208,8 @@ export class TspmDaemon {
async (request: RequestForMethod<'delete'>) => { async (request: RequestForMethod<'delete'>) => {
try { try {
const id = toProcessId(request.id); const id = toProcessId(request.id);
// Ensure desired state reflects stopped before deletion
await this.tspmInstance.setDesiredState(id, 'stopped');
await this.tspmInstance.delete(id); await this.tspmInstance.delete(id);
return { return {
success: true, success: true,
@@ -246,18 +248,7 @@ export class TspmDaemon {
}, },
); );
this.ipcServer.onMessage( // Note: 'remove' is only a CLI alias. Daemon exposes 'delete' only.
'remove',
async (request: RequestForMethod<'remove'>) => {
try {
const id = toProcessId(request.id);
await this.tspmInstance.delete(id);
return { success: true, message: `Process ${id} deleted successfully` };
} catch (error) {
throw new Error(`Failed to remove process: ${error.message}`);
}
},
);
this.ipcServer.onMessage( this.ipcServer.onMessage(
'list', 'list',
@@ -291,6 +282,58 @@ export class TspmDaemon {
}, },
); );
// Resolve target (id:n | name:foo | numeric string) to ProcessId
this.ipcServer.onMessage(
'resolveTarget',
async (request: RequestForMethod<'resolveTarget'>) => {
const raw = String(request.target || '').trim();
if (!raw) {
throw new Error('Empty target');
}
// id:<n>
if (/^id:\s*\d+$/i.test(raw)) {
const idNum = raw.split(':')[1].trim();
const id = toProcessId(idNum);
const config = this.tspmInstance.processConfigs.get(id);
if (!config) throw new Error(`Process ${id} not found`);
return { id, name: config.name } as ResponseForMethod<'resolveTarget'>;
}
// name:<label>
if (/^name:/i.test(raw)) {
const name = raw.slice(raw.indexOf(':') + 1).trim();
if (!name) throw new Error('Missing name after name:');
const matches = Array.from(this.tspmInstance.processConfigs.values()).filter(
(c) => (c.name || '').trim() === name,
);
if (matches.length === 0) {
throw new Error(`No process found with name "${name}"`);
}
if (matches.length > 1) {
const ids = matches.map((c) => String(c.id)).join(', ');
throw new Error(
`Multiple processes found with name "${name}": ids [${ids}]. Please use id:<n>.`,
);
}
return { id: matches[0].id, name } as ResponseForMethod<'resolveTarget'>;
}
// bare numeric id
if (/^\d+$/.test(raw)) {
const id = toProcessId(raw);
const config = this.tspmInstance.processConfigs.get(id);
if (!config) throw new Error(`Process ${id} not found`);
return { id, name: config.name } as ResponseForMethod<'resolveTarget'>;
}
// Unknown format
throw new Error(
'Unsupported target format. Use numeric id (e.g. 1), id:<n> (e.g. id:1), or name:<label> (e.g. name:api).',
);
},
);
// Batch operations handlers // Batch operations handlers
this.ipcServer.onMessage( this.ipcServer.onMessage(
'startAll', 'startAll',

View File

@@ -240,14 +240,6 @@ export interface AddResponse {
} }
// Remove (delete config and stop if running) // Remove (delete config and stop if running)
export interface RemoveRequest {
id: ProcessId;
}
export interface RemoveResponse {
success: boolean;
message?: string;
}
// Update (modify existing config) // Update (modify existing config)
export interface UpdateRequest { export interface UpdateRequest {
@@ -260,6 +252,16 @@ export interface UpdateResponse {
config: IProcessConfig; config: IProcessConfig;
} }
// Resolve a user-provided target (id:n or name:foo or numeric string) to a ProcessId
export interface ResolveTargetRequest {
target: string;
}
export interface ResolveTargetResponse {
id: ProcessId;
name?: string;
}
// Type mappings for methods // Type mappings for methods
export type IpcMethodMap = { export type IpcMethodMap = {
start: { request: StartRequest; response: StartResponse }; start: { request: StartRequest; response: StartResponse };
@@ -269,7 +271,6 @@ export type IpcMethodMap = {
delete: { request: DeleteRequest; response: DeleteResponse }; delete: { request: DeleteRequest; response: DeleteResponse };
add: { request: AddRequest; response: AddResponse }; add: { request: AddRequest; response: AddResponse };
update: { request: UpdateRequest; response: UpdateResponse }; update: { request: UpdateRequest; response: UpdateResponse };
remove: { request: RemoveRequest; response: RemoveResponse };
list: { request: ListRequest; response: ListResponse }; list: { request: ListRequest; response: ListResponse };
describe: { request: DescribeRequest; response: DescribeResponse }; describe: { request: DescribeRequest; response: DescribeResponse };
getLogs: { request: GetLogsRequest; response: GetLogsResponse }; getLogs: { request: GetLogsRequest; response: GetLogsResponse };
@@ -286,6 +287,7 @@ export type IpcMethodMap = {
response: DaemonShutdownResponse; response: DaemonShutdownResponse;
}; };
heartbeat: { request: HeartbeatRequest; response: HeartbeatResponse }; heartbeat: { request: HeartbeatRequest; response: HeartbeatResponse };
resolveTarget: { request: ResolveTargetRequest; response: ResolveTargetResponse };
}; };
// Helper type to extract request type for a method // Helper type to extract request type for a method