Files
Codewalkers/apps/server/agent/lifecycle/signal-manager.ts
Lukas May 34578d39c6 refactor: Restructure monorepo to apps/server/ and apps/web/ layout
Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt
standard monorepo conventions (apps/ for runnable apps, packages/
for reusable libraries). Update all config files, shared package
imports, test fixtures, and documentation to reflect new paths.

Key fixes:
- Update workspace config to ["apps/*", "packages/*"]
- Update tsconfig.json rootDir/include for apps/server/
- Add apps/web/** to vitest exclude list
- Update drizzle.config.ts schema path
- Fix ensure-schema.ts migration path detection (3 levels up in dev,
  2 levels up in dist)
- Fix tests/integration/cli-server.test.ts import paths
- Update packages/shared imports to apps/server/ paths
- Update all docs/ files with new paths
2026-03-03 11:22:53 +01:00

178 lines
5.5 KiB
TypeScript

/**
* SignalManager — Centralized signal.json operations with atomic file handling.
*
* Provides robust signal.json management with proper error handling and atomic
* operations. Replaces scattered signal detection logic throughout the codebase.
*/
import { readFile, unlink, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { createModuleLogger } from '../../logger/index.js';
const log = createModuleLogger('signal-manager');
export interface SignalData {
status: 'done' | 'questions' | 'error';
questions?: Array<{
id: string;
question: string;
options?: string[];
}>;
error?: string;
}
export interface SignalManager {
clearSignal(agentWorkdir: string): Promise<void>;
checkSignalExists(agentWorkdir: string): Promise<boolean>;
readSignal(agentWorkdir: string): Promise<SignalData | null>;
waitForSignal(agentWorkdir: string, timeoutMs: number): Promise<SignalData | null>;
validateSignalFile(signalPath: string): Promise<boolean>;
}
export class FileSystemSignalManager implements SignalManager {
/**
* Clear signal.json file atomically. Always called before spawn/resume.
* This prevents race conditions in completion detection.
*/
async clearSignal(agentWorkdir: string): Promise<void> {
const signalPath = join(agentWorkdir, '.cw/output/signal.json');
try {
await unlink(signalPath);
log.debug({ agentWorkdir, signalPath }, 'signal.json cleared successfully');
} catch (error: any) {
if (error.code !== 'ENOENT') {
log.warn({ agentWorkdir, signalPath, error: error.message }, 'failed to clear signal.json');
throw error;
}
// File doesn't exist - that's fine, it's already "cleared"
log.debug({ agentWorkdir, signalPath }, 'signal.json already absent (nothing to clear)');
}
}
/**
* Check if signal.json file exists synchronously.
*/
async checkSignalExists(agentWorkdir: string): Promise<boolean> {
const signalPath = join(agentWorkdir, '.cw/output/signal.json');
return existsSync(signalPath);
}
/**
* Read and parse signal.json file with robust error handling.
* Returns null if file doesn't exist or is invalid.
*/
async readSignal(agentWorkdir: string): Promise<SignalData | null> {
const signalPath = join(agentWorkdir, '.cw/output/signal.json');
try {
if (!existsSync(signalPath)) {
return null;
}
const content = await readFile(signalPath, 'utf-8');
const trimmed = content.trim();
if (!trimmed) {
log.debug({ agentWorkdir, signalPath }, 'signal.json is empty');
return null;
}
const signal = JSON.parse(trimmed) as SignalData;
// Basic validation
if (!signal.status || !['done', 'questions', 'error'].includes(signal.status)) {
log.warn({ agentWorkdir, signalPath, signal }, 'signal.json has invalid status');
return null;
}
log.debug({ agentWorkdir, signalPath, status: signal.status }, 'signal.json read successfully');
return signal;
} catch (error) {
log.warn({
agentWorkdir,
signalPath,
error: error instanceof Error ? error.message : String(error)
}, 'failed to read or parse signal.json');
return null;
}
}
/**
* Wait for signal.json to appear and be valid, with exponential backoff polling.
* Returns null if timeout is reached or signal is never valid.
*/
async waitForSignal(agentWorkdir: string, timeoutMs: number): Promise<SignalData | null> {
const startTime = Date.now();
const signalPath = join(agentWorkdir, '.cw/output/signal.json');
let attempt = 0;
log.debug({ agentWorkdir, timeoutMs }, 'waiting for signal.json to appear');
while (Date.now() - startTime < timeoutMs) {
const signal = await this.readSignal(agentWorkdir);
if (signal) {
log.debug({
agentWorkdir,
signalPath,
status: signal.status,
waitTime: Date.now() - startTime
}, 'signal.json found and valid');
return signal;
}
// Exponential backoff: 100ms, 200ms, 400ms, 800ms, then 1s max
const delay = Math.min(100 * Math.pow(2, attempt), 1000);
await new Promise(resolve => setTimeout(resolve, delay));
attempt++;
}
log.debug({
agentWorkdir,
signalPath,
timeoutMs,
totalWaitTime: Date.now() - startTime
}, 'timeout waiting for signal.json');
return null;
}
/**
* Validate that a signal file is complete and properly formatted.
* Used to detect if file is still being written vs. truly missing/incomplete.
*/
async validateSignalFile(signalPath: string): Promise<boolean> {
try {
if (!existsSync(signalPath)) {
return false;
}
// Check file is not empty and appears complete
const stats = await stat(signalPath);
if (stats.size === 0) {
return false;
}
const content = await readFile(signalPath, 'utf-8');
const trimmed = content.trim();
if (!trimmed) {
return false;
}
// Check if JSON structure appears complete
const endsCorrectly = trimmed.endsWith('}') || trimmed.endsWith(']');
if (!endsCorrectly) {
return false;
}
// Try to parse as JSON to ensure it's valid
JSON.parse(trimmed);
return true;
} catch (error) {
log.debug({ signalPath, error: error instanceof Error ? error.message : String(error) }, 'signal file validation failed');
return false;
}
}
}