gsd-unsupervised 1.0.0
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.
- package/README.md +263 -0
- package/bin/gsd-unsupervised +3 -0
- package/bin/start-daemon.sh +12 -0
- package/bin/unsupervised-gsd +2 -0
- package/dist/agent-runner.d.ts +26 -0
- package/dist/agent-runner.js +111 -0
- package/dist/agent-runner.spawn.test.d.ts +1 -0
- package/dist/agent-runner.spawn.test.js +128 -0
- package/dist/agent-runner.test.d.ts +1 -0
- package/dist/agent-runner.test.js +26 -0
- package/dist/bootstrap/wsl-bootstrap.d.ts +11 -0
- package/dist/bootstrap/wsl-bootstrap.js +14 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +172 -0
- package/dist/config/paths.d.ts +8 -0
- package/dist/config/paths.js +36 -0
- package/dist/config/wsl.d.ts +4 -0
- package/dist/config/wsl.js +43 -0
- package/dist/config.d.ts +79 -0
- package/dist/config.js +95 -0
- package/dist/config.test.d.ts +1 -0
- package/dist/config.test.js +27 -0
- package/dist/cursor-agent.d.ts +17 -0
- package/dist/cursor-agent.invoker.test.d.ts +1 -0
- package/dist/cursor-agent.invoker.test.js +150 -0
- package/dist/cursor-agent.js +156 -0
- package/dist/cursor-agent.test.d.ts +1 -0
- package/dist/cursor-agent.test.js +60 -0
- package/dist/daemon.d.ts +17 -0
- package/dist/daemon.js +374 -0
- package/dist/git.d.ts +23 -0
- package/dist/git.js +76 -0
- package/dist/goals.d.ts +34 -0
- package/dist/goals.js +148 -0
- package/dist/gsd-state.d.ts +49 -0
- package/dist/gsd-state.js +76 -0
- package/dist/init-wizard.d.ts +5 -0
- package/dist/init-wizard.js +96 -0
- package/dist/lifecycle.d.ts +41 -0
- package/dist/lifecycle.js +103 -0
- package/dist/lifecycle.test.d.ts +1 -0
- package/dist/lifecycle.test.js +116 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +31 -0
- package/dist/notifier.d.ts +6 -0
- package/dist/notifier.js +37 -0
- package/dist/orchestrator.d.ts +35 -0
- package/dist/orchestrator.js +791 -0
- package/dist/resource-governor.d.ts +54 -0
- package/dist/resource-governor.js +57 -0
- package/dist/resource-governor.test.d.ts +1 -0
- package/dist/resource-governor.test.js +33 -0
- package/dist/resume-pointer.d.ts +36 -0
- package/dist/resume-pointer.js +116 -0
- package/dist/roadmap-parser.d.ts +24 -0
- package/dist/roadmap-parser.js +105 -0
- package/dist/roadmap-parser.test.d.ts +1 -0
- package/dist/roadmap-parser.test.js +57 -0
- package/dist/session-log.d.ts +53 -0
- package/dist/session-log.js +92 -0
- package/dist/session-log.test.d.ts +1 -0
- package/dist/session-log.test.js +146 -0
- package/dist/state-index.d.ts +5 -0
- package/dist/state-index.js +31 -0
- package/dist/state-parser.d.ts +13 -0
- package/dist/state-parser.js +82 -0
- package/dist/state-parser.test.d.ts +1 -0
- package/dist/state-parser.test.js +228 -0
- package/dist/state-types.d.ts +20 -0
- package/dist/state-types.js +1 -0
- package/dist/state-watcher.d.ts +49 -0
- package/dist/state-watcher.js +148 -0
- package/dist/status-server.d.ts +112 -0
- package/dist/status-server.js +379 -0
- package/dist/status-server.test.d.ts +1 -0
- package/dist/status-server.test.js +206 -0
- package/dist/stream-events.d.ts +423 -0
- package/dist/stream-events.js +87 -0
- package/dist/stream-events.test.d.ts +1 -0
- package/dist/stream-events.test.js +304 -0
- package/dist/todos-api.d.ts +5 -0
- package/dist/todos-api.js +35 -0
- package/package.json +54 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { writeFile, unlink } from 'node:fs/promises';
|
|
3
|
+
import { runAgent } from './agent-runner.js';
|
|
4
|
+
import { appendSessionLog, } from './session-log.js';
|
|
5
|
+
import { getCursorBinaryPath } from './config/paths.js';
|
|
6
|
+
export function createCursorAgentInvoker(agentConfig) {
|
|
7
|
+
return async (command, workspaceDir, logger, logContext) => {
|
|
8
|
+
const cmdString = command.args
|
|
9
|
+
? `${command.command} ${command.args}`
|
|
10
|
+
: command.command;
|
|
11
|
+
const prompt = 'Execute in non-interactive/YOLO mode. Auto-approve all confirmations. ' +
|
|
12
|
+
'Do not ask the user any questions — make reasonable decisions autonomously.\n\n' +
|
|
13
|
+
cmdString;
|
|
14
|
+
logger.info({ command: cmdString }, `Invoking cursor-agent: ${cmdString}`);
|
|
15
|
+
const baseEntry = {
|
|
16
|
+
timestamp: new Date().toISOString(),
|
|
17
|
+
goalTitle: logContext?.goalTitle ?? '',
|
|
18
|
+
phase: command.command,
|
|
19
|
+
phaseNumber: logContext?.phaseNumber,
|
|
20
|
+
planNumber: logContext?.planNumber,
|
|
21
|
+
sessionId: null,
|
|
22
|
+
command: cmdString,
|
|
23
|
+
};
|
|
24
|
+
await appendSessionLog(agentConfig.sessionLogPath, {
|
|
25
|
+
...baseEntry,
|
|
26
|
+
status: 'running',
|
|
27
|
+
});
|
|
28
|
+
const startMs = Date.now();
|
|
29
|
+
const heartbeatPath = agentConfig.heartbeatPath;
|
|
30
|
+
const heartbeatIntervalMs = agentConfig.heartbeatIntervalMs ?? 15_000;
|
|
31
|
+
let heartbeatTimer = null;
|
|
32
|
+
const stopHeartbeat = async () => {
|
|
33
|
+
if (heartbeatTimer) {
|
|
34
|
+
clearInterval(heartbeatTimer);
|
|
35
|
+
heartbeatTimer = null;
|
|
36
|
+
}
|
|
37
|
+
if (heartbeatPath) {
|
|
38
|
+
try {
|
|
39
|
+
await unlink(heartbeatPath);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// ignore
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
if (heartbeatPath) {
|
|
47
|
+
const tick = () => {
|
|
48
|
+
writeFile(heartbeatPath, new Date().toISOString(), 'utf-8').catch(() => { });
|
|
49
|
+
};
|
|
50
|
+
tick();
|
|
51
|
+
heartbeatTimer = setInterval(tick, heartbeatIntervalMs);
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const result = await runAgent({
|
|
55
|
+
agentPath: agentConfig.agentPath,
|
|
56
|
+
workspace: workspaceDir,
|
|
57
|
+
prompt,
|
|
58
|
+
env: process.env.CURSOR_API_KEY
|
|
59
|
+
? { CURSOR_API_KEY: process.env.CURSOR_API_KEY }
|
|
60
|
+
: undefined,
|
|
61
|
+
timeoutMs: agentConfig.defaultTimeoutMs,
|
|
62
|
+
onEvent: (event) => {
|
|
63
|
+
if (event.type === 'system' && event.subtype === 'init') {
|
|
64
|
+
logger.info({ sessionId: event.session_id }, 'Agent session started');
|
|
65
|
+
}
|
|
66
|
+
else if (event.type === 'tool_call') {
|
|
67
|
+
logger.debug({ toolName: event.tool_call.name, callId: event.call_id }, `Tool call: ${event.tool_call.name}`);
|
|
68
|
+
}
|
|
69
|
+
else if (event.type === 'result') {
|
|
70
|
+
logger.info({ isError: event.is_error, durationMs: event.duration_ms }, `Agent result: ${event.is_error ? 'error' : 'success'}`);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
const durationMs = Date.now() - startMs;
|
|
75
|
+
const timedOut = result.timedOut;
|
|
76
|
+
await stopHeartbeat();
|
|
77
|
+
if (timedOut) {
|
|
78
|
+
await appendSessionLog(agentConfig.sessionLogPath, {
|
|
79
|
+
...baseEntry,
|
|
80
|
+
sessionId: result.sessionId,
|
|
81
|
+
status: 'timeout',
|
|
82
|
+
durationMs,
|
|
83
|
+
error: `Agent timed out after ${agentConfig.defaultTimeoutMs}ms`,
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
success: false,
|
|
87
|
+
error: `Agent timed out after ${agentConfig.defaultTimeoutMs}ms`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (result.exitCode === 0 && result.resultEvent) {
|
|
91
|
+
await appendSessionLog(agentConfig.sessionLogPath, {
|
|
92
|
+
...baseEntry,
|
|
93
|
+
sessionId: result.sessionId,
|
|
94
|
+
status: 'done',
|
|
95
|
+
durationMs,
|
|
96
|
+
});
|
|
97
|
+
return { success: true, output: result.resultEvent.result };
|
|
98
|
+
}
|
|
99
|
+
const stderrSnippet = result.stderr.slice(0, 500);
|
|
100
|
+
const errorMsg = result.resultEvent?.is_error
|
|
101
|
+
? `Agent error: ${result.resultEvent.result}`
|
|
102
|
+
: `Agent failed (exit ${result.exitCode})${stderrSnippet ? `: ${stderrSnippet}` : ''}`;
|
|
103
|
+
await appendSessionLog(agentConfig.sessionLogPath, {
|
|
104
|
+
...baseEntry,
|
|
105
|
+
sessionId: result.sessionId,
|
|
106
|
+
status: 'crashed',
|
|
107
|
+
durationMs,
|
|
108
|
+
error: errorMsg,
|
|
109
|
+
});
|
|
110
|
+
return { success: false, error: errorMsg };
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
await stopHeartbeat();
|
|
114
|
+
const durationMs = Date.now() - startMs;
|
|
115
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
116
|
+
await appendSessionLog(agentConfig.sessionLogPath, {
|
|
117
|
+
...baseEntry,
|
|
118
|
+
status: 'crashed',
|
|
119
|
+
durationMs,
|
|
120
|
+
error: message,
|
|
121
|
+
}).catch(() => { });
|
|
122
|
+
return { success: false, error: `Agent invocation failed: ${message}` };
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
export function validateCursorApiKey() {
|
|
127
|
+
const key = process.env.CURSOR_API_KEY;
|
|
128
|
+
if (!key || key.trim() === '') {
|
|
129
|
+
throw new Error('CURSOR_API_KEY environment variable is not set or empty.\n' +
|
|
130
|
+
'Set CURSOR_API_KEY environment variable. Generate from Cursor Dashboard → Cloud Agents → User API Keys.');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** Agent-agnostic factory: returns the appropriate invoker for the given agent ID. */
|
|
134
|
+
export function createAgentInvoker(agentId, config) {
|
|
135
|
+
switch (agentId) {
|
|
136
|
+
case 'cursor':
|
|
137
|
+
return createCursorAgentInvoker({
|
|
138
|
+
agentPath: getCursorBinaryPath(config),
|
|
139
|
+
defaultTimeoutMs: config.agentTimeoutMs,
|
|
140
|
+
sessionLogPath: config.sessionLogPath,
|
|
141
|
+
heartbeatPath: path.join(config.workspaceRoot, '.planning', 'heartbeat.txt'),
|
|
142
|
+
heartbeatIntervalMs: 15_000,
|
|
143
|
+
});
|
|
144
|
+
case 'claude-code':
|
|
145
|
+
case 'gemini-cli':
|
|
146
|
+
case 'codex': {
|
|
147
|
+
// TODO: Implement real adapters when those agents support GSD NDJSON/heartbeat contract.
|
|
148
|
+
const stub = async (command, workspaceDir, logger, _logContext) => {
|
|
149
|
+
logger.info(`Stub (${agentId}): would invoke agent with "${command.command} ${command.args ?? ''}" in ${workspaceDir}`);
|
|
150
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
151
|
+
return { success: true, output: 'stub' };
|
|
152
|
+
};
|
|
153
|
+
return stub;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { createAgentInvoker } from './cursor-agent.js';
|
|
3
|
+
import { initLogger } from './logger.js';
|
|
4
|
+
const baseConfig = {
|
|
5
|
+
goalsPath: './goals.md',
|
|
6
|
+
parallel: false,
|
|
7
|
+
maxConcurrent: 3,
|
|
8
|
+
maxCpuFraction: 0.75,
|
|
9
|
+
verbose: false,
|
|
10
|
+
logLevel: 'info',
|
|
11
|
+
workspaceRoot: '/tmp/test',
|
|
12
|
+
agent: 'cursor',
|
|
13
|
+
cursorAgentPath: '/usr/bin/cursor-agent',
|
|
14
|
+
agentTimeoutMs: 60_000,
|
|
15
|
+
sessionLogPath: '/tmp/session-log.jsonl',
|
|
16
|
+
stateWatchDebounceMs: 500,
|
|
17
|
+
requireCleanGitBeforePlan: true,
|
|
18
|
+
autoCheckpoint: false,
|
|
19
|
+
};
|
|
20
|
+
describe('createAgentInvoker', () => {
|
|
21
|
+
const logger = initLogger({ level: 'silent', pretty: false });
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
vi.clearAllMocks();
|
|
24
|
+
});
|
|
25
|
+
it('returns cursor adapter for agent cursor', () => {
|
|
26
|
+
const invoker = createAgentInvoker('cursor', baseConfig);
|
|
27
|
+
expect(invoker).toBeTypeOf('function');
|
|
28
|
+
expect(invoker.length).toBe(4); // command, workspaceDir, logger, logContext
|
|
29
|
+
});
|
|
30
|
+
it('returns non-throwing stub for claude-code', async () => {
|
|
31
|
+
const invoker = createAgentInvoker('claude-code', baseConfig);
|
|
32
|
+
const infoSpy = vi.spyOn(logger, 'info');
|
|
33
|
+
const result = await invoker({ command: '/gsd/execute-plan', args: 'foo', description: 'test' }, '/workspace', logger);
|
|
34
|
+
expect(result.success).toBe(true);
|
|
35
|
+
expect(result.output).toBe('stub');
|
|
36
|
+
expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('Stub (claude-code)'));
|
|
37
|
+
});
|
|
38
|
+
it('returns non-throwing stub for gemini-cli', async () => {
|
|
39
|
+
const invoker = createAgentInvoker('gemini-cli', baseConfig);
|
|
40
|
+
const infoSpy = vi.spyOn(logger, 'info');
|
|
41
|
+
const result = await invoker({ command: '/gsd/plan-phase', args: '1', description: 'test' }, '/workspace', logger);
|
|
42
|
+
expect(result.success).toBe(true);
|
|
43
|
+
expect(result.output).toBe('stub');
|
|
44
|
+
expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('Stub (gemini-cli)'));
|
|
45
|
+
});
|
|
46
|
+
it('returns non-throwing stub for codex', async () => {
|
|
47
|
+
const invoker = createAgentInvoker('codex', baseConfig);
|
|
48
|
+
const infoSpy = vi.spyOn(logger, 'info');
|
|
49
|
+
const result = await invoker({ command: '/gsd/new-project', description: 'test' }, '/workspace', logger);
|
|
50
|
+
expect(result.success).toBe(true);
|
|
51
|
+
expect(result.output).toBe('stub');
|
|
52
|
+
expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('Stub (codex)'));
|
|
53
|
+
});
|
|
54
|
+
it('stub adapters have same call signature as cursor', async () => {
|
|
55
|
+
const stubInvoker = createAgentInvoker('claude-code', baseConfig);
|
|
56
|
+
const result = await stubInvoker({ command: '/gsd/execute-plan', args: 'path', description: 'desc' }, '/workspace', logger, { goalTitle: 'Goal', phaseNumber: 1, planNumber: 1 });
|
|
57
|
+
expect(result).toMatchObject({ success: true, output: 'stub' });
|
|
58
|
+
expect(result.error).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
});
|
package/dist/daemon.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AutopilotConfig } from './config.js';
|
|
2
|
+
import type { Logger } from './logger.js';
|
|
3
|
+
/**
|
|
4
|
+
* Orchestration: queue + hot-reload + webhook + parallel pool.
|
|
5
|
+
*
|
|
6
|
+
* - Goals are loaded at start and can be hot-reloaded when goals.md changes
|
|
7
|
+
* (chokidar); new pending goals are merged into the queue. Webhook (POST
|
|
8
|
+
* /api/goals, /api/todos, Twilio POST /webhook/twilio) can add goals/todos
|
|
9
|
+
* and push into the queue immediately.
|
|
10
|
+
* - Prioritization: buildExecutionPlan() orders by [priority:N] then original
|
|
11
|
+
* order. parallelGroup/dependsOn are parsed but not used for scheduling.
|
|
12
|
+
* - Pool: up to maxConcurrent workers when parallel is enabled; single workspace
|
|
13
|
+
* uses one mutex so only one goal runs at a time (phase-level parallel still
|
|
14
|
+
* applies inside execute-phase). Multi-workspace would allow true parallel goals.
|
|
15
|
+
*/
|
|
16
|
+
export declare function runDaemon(config: AutopilotConfig, logger: Logger): Promise<void>;
|
|
17
|
+
export declare function registerShutdownHandlers(logger: Logger): void;
|
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { stat, writeFile } from 'node:fs/promises';
|
|
5
|
+
import chokidar from 'chokidar';
|
|
6
|
+
import { createChildLogger } from './logger.js';
|
|
7
|
+
import { loadGoals, getPendingGoals, buildExecutionPlan } from './goals.js';
|
|
8
|
+
import { orchestrateGoal } from './orchestrator.js';
|
|
9
|
+
import { createAgentInvoker } from './cursor-agent.js';
|
|
10
|
+
import { StateWatcher } from './state-watcher.js';
|
|
11
|
+
import { inspectForCrashedSessions, appendSessionLog, } from './session-log.js';
|
|
12
|
+
import { computeResumePointer } from './resume-pointer.js';
|
|
13
|
+
import { createStatusServer, readPlanningConfig } from './status-server.js';
|
|
14
|
+
import { sendSms } from './notifier.js';
|
|
15
|
+
import { writeGsdState } from './gsd-state.js';
|
|
16
|
+
import { addTodo } from './todos-api.js';
|
|
17
|
+
let shuttingDown = false;
|
|
18
|
+
async function updateState(config, update) {
|
|
19
|
+
if (!config.statePath)
|
|
20
|
+
return;
|
|
21
|
+
try {
|
|
22
|
+
await writeGsdState(config.workspaceRoot, update, config.statePath);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// best-effort
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Orchestration: queue + hot-reload + webhook + parallel pool.
|
|
30
|
+
*
|
|
31
|
+
* - Goals are loaded at start and can be hot-reloaded when goals.md changes
|
|
32
|
+
* (chokidar); new pending goals are merged into the queue. Webhook (POST
|
|
33
|
+
* /api/goals, /api/todos, Twilio POST /webhook/twilio) can add goals/todos
|
|
34
|
+
* and push into the queue immediately.
|
|
35
|
+
* - Prioritization: buildExecutionPlan() orders by [priority:N] then original
|
|
36
|
+
* order. parallelGroup/dependsOn are parsed but not used for scheduling.
|
|
37
|
+
* - Pool: up to maxConcurrent workers when parallel is enabled; single workspace
|
|
38
|
+
* uses one mutex so only one goal runs at a time (phase-level parallel still
|
|
39
|
+
* applies inside execute-phase). Multi-workspace would allow true parallel goals.
|
|
40
|
+
*/
|
|
41
|
+
export async function runDaemon(config, logger) {
|
|
42
|
+
const goals = await loadGoals(config.goalsPath);
|
|
43
|
+
const pending = getPendingGoals(goals);
|
|
44
|
+
if (pending.length === 0) {
|
|
45
|
+
logger.info('No pending goals in queue');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const plan = buildExecutionPlan(pending);
|
|
49
|
+
const queue = [...plan.ordered];
|
|
50
|
+
const running = new Set();
|
|
51
|
+
let wakeResolver = null;
|
|
52
|
+
const wake = () => {
|
|
53
|
+
if (wakeResolver) {
|
|
54
|
+
wakeResolver();
|
|
55
|
+
wakeResolver = null;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const waitForWake = (ms) => new Promise((resolve) => {
|
|
59
|
+
wakeResolver = resolve;
|
|
60
|
+
setTimeout(() => {
|
|
61
|
+
if (wakeResolver === resolve) {
|
|
62
|
+
wakeResolver = null;
|
|
63
|
+
resolve();
|
|
64
|
+
}
|
|
65
|
+
}, ms);
|
|
66
|
+
});
|
|
67
|
+
const addToQueue = (goal) => {
|
|
68
|
+
if (running.has(goal.title))
|
|
69
|
+
return;
|
|
70
|
+
if (queue.some((g) => g.title === goal.title))
|
|
71
|
+
return;
|
|
72
|
+
queue.push(goal);
|
|
73
|
+
wake();
|
|
74
|
+
};
|
|
75
|
+
const totalGoalsInitial = queue.length;
|
|
76
|
+
logger.info({ count: totalGoalsInitial }, `Found ${totalGoalsInitial} pending goals`);
|
|
77
|
+
await updateState(config, {
|
|
78
|
+
daemonPid: process.pid,
|
|
79
|
+
startedAt: new Date().toISOString(),
|
|
80
|
+
});
|
|
81
|
+
const heartbeatIntervalMs = 30_000;
|
|
82
|
+
const heartbeatTimer = config.statePath &&
|
|
83
|
+
setInterval(() => {
|
|
84
|
+
updateState(config, { lastHeartbeat: new Date().toISOString() }).catch(() => { });
|
|
85
|
+
}, heartbeatIntervalMs);
|
|
86
|
+
const agent = createAgentInvoker(config.agent, config);
|
|
87
|
+
const stateMdPath = path.join(config.workspaceRoot, '.planning', 'STATE.md');
|
|
88
|
+
const heartbeatPath = path.join(config.workspaceRoot, '.planning', 'heartbeat.txt');
|
|
89
|
+
const planningConfigPath = path.join(config.workspaceRoot, '.planning', 'config.json');
|
|
90
|
+
const heartbeatTimeoutMs = 60_000;
|
|
91
|
+
let effectiveParallel = config.parallel;
|
|
92
|
+
try {
|
|
93
|
+
const planning = await readPlanningConfig(planningConfigPath);
|
|
94
|
+
if (planning.parallelization?.enabled !== undefined) {
|
|
95
|
+
effectiveParallel = planning.parallelization.enabled;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// use config.parallel
|
|
100
|
+
}
|
|
101
|
+
const numWorkers = effectiveParallel ? config.maxConcurrent : 1;
|
|
102
|
+
if (effectiveParallel) {
|
|
103
|
+
logger.info({ maxConcurrent: config.maxConcurrent }, `Parallel mode: up to ${config.maxConcurrent} workers (single workspace => one goal at a time via mutex)`);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
logger.info('Sequential mode: processing goals one at a time');
|
|
107
|
+
}
|
|
108
|
+
let currentGoal = null;
|
|
109
|
+
let completedCount = 0;
|
|
110
|
+
let statusServerClose = null;
|
|
111
|
+
let ngrokClose = null;
|
|
112
|
+
const webhookOptions = {
|
|
113
|
+
goalsPath: config.goalsPath,
|
|
114
|
+
workspaceRoot: config.workspaceRoot,
|
|
115
|
+
onQueueGoal: addToQueue,
|
|
116
|
+
getRunningTitles: () => Array.from(running),
|
|
117
|
+
addTodo: (title, area) => addTodo(config.workspaceRoot, title, area ?? 'general'),
|
|
118
|
+
};
|
|
119
|
+
if (config.statusServerPort) {
|
|
120
|
+
const { close } = createStatusServer(config.statusServerPort, () => ({
|
|
121
|
+
running: !shuttingDown,
|
|
122
|
+
currentGoal: currentGoal ?? undefined,
|
|
123
|
+
}), {
|
|
124
|
+
stateMdPath,
|
|
125
|
+
sessionLogPath: config.sessionLogPath,
|
|
126
|
+
workspaceRoot: config.workspaceRoot,
|
|
127
|
+
planningConfigPath,
|
|
128
|
+
webhook: webhookOptions,
|
|
129
|
+
});
|
|
130
|
+
statusServerClose = close;
|
|
131
|
+
logger.info({ port: config.statusServerPort }, 'Status server listening');
|
|
132
|
+
if (config.ngrok) {
|
|
133
|
+
const port = config.statusServerPort;
|
|
134
|
+
const child = spawn('ngrok', ['http', String(port)], {
|
|
135
|
+
stdio: 'inherit',
|
|
136
|
+
shell: false,
|
|
137
|
+
});
|
|
138
|
+
child.on('error', (err) => {
|
|
139
|
+
logger.warn({ err, port }, 'ngrok failed to start');
|
|
140
|
+
});
|
|
141
|
+
child.on('exit', (code, signal) => {
|
|
142
|
+
if (!shuttingDown && (code !== 0 || signal)) {
|
|
143
|
+
logger.info({ code, signal }, 'ngrok exited');
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
ngrokClose = () => new Promise((resolve) => {
|
|
147
|
+
if (!child.killed && child.pid) {
|
|
148
|
+
child.once('exit', () => resolve());
|
|
149
|
+
child.kill('SIGTERM');
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
resolve();
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
logger.info({ port }, 'ngrok started (ngrok http %s)', port);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
let resumeFrom = null;
|
|
159
|
+
if (queue.length > 0) {
|
|
160
|
+
const crashed = await inspectForCrashedSessions(config.sessionLogPath);
|
|
161
|
+
if (crashed?.status === 'running') {
|
|
162
|
+
try {
|
|
163
|
+
const st = await stat(heartbeatPath);
|
|
164
|
+
const ageMs = Date.now() - st.mtime.getTime();
|
|
165
|
+
if (ageMs > heartbeatTimeoutMs) {
|
|
166
|
+
await appendSessionLog(config.sessionLogPath, {
|
|
167
|
+
...crashed,
|
|
168
|
+
timestamp: new Date().toISOString(),
|
|
169
|
+
status: 'crashed',
|
|
170
|
+
error: `Heartbeat timeout (>${heartbeatTimeoutMs / 1000}s)`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
await appendSessionLog(config.sessionLogPath, {
|
|
176
|
+
...crashed,
|
|
177
|
+
timestamp: new Date().toISOString(),
|
|
178
|
+
status: 'crashed',
|
|
179
|
+
error: 'Heartbeat timeout (missing)',
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
resumeFrom = await computeResumePointer({
|
|
184
|
+
sessionLogPath: config.sessionLogPath,
|
|
185
|
+
stateMdPath,
|
|
186
|
+
goalTitle: queue[0]?.title ?? '',
|
|
187
|
+
});
|
|
188
|
+
if (resumeFrom) {
|
|
189
|
+
logger.info({ phaseNumber: resumeFrom.phaseNumber, planNumber: resumeFrom.planNumber }, 'Resuming from phase %s plan %s due to previous crash', resumeFrom.phaseNumber, resumeFrom.planNumber === 0 ? '1 (first)' : resumeFrom.planNumber);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const pauseFlagPath = path.join(config.workspaceRoot, '.pause-autopilot');
|
|
193
|
+
const goalsWatcher = chokidar.watch(config.goalsPath, { ignoreInitial: true });
|
|
194
|
+
goalsWatcher.on('change', async () => {
|
|
195
|
+
try {
|
|
196
|
+
const fresh = await loadGoals(config.goalsPath);
|
|
197
|
+
const newPending = getPendingGoals(fresh);
|
|
198
|
+
const newPlan = buildExecutionPlan(newPending);
|
|
199
|
+
for (const g of newPlan.ordered)
|
|
200
|
+
addToQueue(g);
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
logger.warn({ err, path: config.goalsPath }, 'Hot-reload goals failed');
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
const workspaceMutex = (() => {
|
|
207
|
+
let locked = false;
|
|
208
|
+
const waiters = [];
|
|
209
|
+
return {
|
|
210
|
+
async run(fn) {
|
|
211
|
+
while (locked) {
|
|
212
|
+
await new Promise((r) => waiters.push(r));
|
|
213
|
+
}
|
|
214
|
+
locked = true;
|
|
215
|
+
try {
|
|
216
|
+
return await fn();
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
locked = false;
|
|
220
|
+
const next = waiters.shift();
|
|
221
|
+
if (next)
|
|
222
|
+
next();
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
})();
|
|
227
|
+
async function runOneGoal(goal, useResumeFrom) {
|
|
228
|
+
let plannedUpToPhaseNum = 0;
|
|
229
|
+
let currentResumeFrom = useResumeFrom;
|
|
230
|
+
let watcher = null;
|
|
231
|
+
try {
|
|
232
|
+
watcher = new StateWatcher({
|
|
233
|
+
stateMdPath,
|
|
234
|
+
debounceMs: config.stateWatchDebounceMs,
|
|
235
|
+
logger: createChildLogger(logger, 'state-watcher'),
|
|
236
|
+
});
|
|
237
|
+
watcher.on('state_changed', (payload) => {
|
|
238
|
+
logger.debug({
|
|
239
|
+
phase: payload.current.phaseNumber,
|
|
240
|
+
plan: payload.current.planNumber,
|
|
241
|
+
status: payload.current.status,
|
|
242
|
+
}, 'state_changed');
|
|
243
|
+
});
|
|
244
|
+
watcher.on('phase_advanced', (p) => logger.info({ fromPhase: p.fromPhase, toPhase: p.toPhase, phaseName: p.phaseName }, 'phase_advanced'));
|
|
245
|
+
watcher.on('plan_advanced', (p) => logger.info({ phaseNumber: p.phaseNumber, fromPlan: p.fromPlan, toPlan: p.toPlan }, 'plan_advanced'));
|
|
246
|
+
watcher.on('phase_completed', (p) => logger.info({ phaseNumber: p.phaseNumber, phaseName: p.phaseName }, 'phase_completed'));
|
|
247
|
+
watcher.on('goal_completed', () => logger.info('goal_completed'));
|
|
248
|
+
watcher.start();
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
logger.warn({ err, stateMdPath }, 'StateWatcher not started — proceeding without state watching');
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
let attempt = 0;
|
|
255
|
+
while (attempt < 3) {
|
|
256
|
+
attempt++;
|
|
257
|
+
try {
|
|
258
|
+
await orchestrateGoal({
|
|
259
|
+
goal,
|
|
260
|
+
config,
|
|
261
|
+
logger,
|
|
262
|
+
agent,
|
|
263
|
+
isShuttingDown: () => shuttingDown,
|
|
264
|
+
onProgress: (snapshot) => {
|
|
265
|
+
if (snapshot.status.startsWith('Planned phase')) {
|
|
266
|
+
plannedUpToPhaseNum = Math.max(plannedUpToPhaseNum, snapshot.phaseNumber);
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
resumeFrom: currentResumeFrom,
|
|
270
|
+
skipToPhase: attempt > 1 && plannedUpToPhaseNum > 0 ? plannedUpToPhaseNum + 1 : null,
|
|
271
|
+
});
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
276
|
+
logger.error({ err, goal: goal.title, attempt, maxAttempts: 3 }, `Failed goal attempt ${attempt}/3: ${goal.title}`);
|
|
277
|
+
if (attempt >= 3) {
|
|
278
|
+
try {
|
|
279
|
+
await writeFile(pauseFlagPath, `Paused after 3 failed attempts for goal: ${goal.title}\nLast error: ${msg}\n`, 'utf-8');
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// ignore
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
await sendSms(`GSD daemon paused after 3 retries.\nGoal: ${goal.title}\nError: ${msg}`);
|
|
286
|
+
}
|
|
287
|
+
catch (smsErr) {
|
|
288
|
+
logger.warn({ err: smsErr }, 'SMS notification failed');
|
|
289
|
+
}
|
|
290
|
+
while (existsSync(pauseFlagPath) && !shuttingDown) {
|
|
291
|
+
logger.info('Pause flag (.pause-autopilot) detected – sleeping 60s');
|
|
292
|
+
await new Promise((r) => setTimeout(r, 60_000));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
currentResumeFrom = null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
if (watcher)
|
|
303
|
+
watcher.stop();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const runWorker = async () => {
|
|
307
|
+
while (!shuttingDown) {
|
|
308
|
+
while (existsSync(pauseFlagPath)) {
|
|
309
|
+
logger.info('Pause flag (.pause-autopilot) detected – sleeping 60s');
|
|
310
|
+
await new Promise((r) => setTimeout(r, 60_000));
|
|
311
|
+
}
|
|
312
|
+
const goal = queue.shift() ?? null;
|
|
313
|
+
if (!goal) {
|
|
314
|
+
if (running.size === 0)
|
|
315
|
+
break;
|
|
316
|
+
await waitForWake(5000);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
running.add(goal.title);
|
|
320
|
+
currentGoal = goal.title;
|
|
321
|
+
completedCount++;
|
|
322
|
+
const totalCount = completedCount + queue.length;
|
|
323
|
+
await updateState(config, { currentGoal: goal.title, progress: `${completedCount}/${totalCount}` });
|
|
324
|
+
if (shuttingDown) {
|
|
325
|
+
queue.unshift(goal);
|
|
326
|
+
running.delete(goal.title);
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
logger.info({ goal: goal.title }, `Processing goal: ${goal.title}`);
|
|
330
|
+
const isFirst = completedCount === 1 && resumeFrom !== null;
|
|
331
|
+
await workspaceMutex.run(() => runOneGoal(goal, isFirst ? resumeFrom : null));
|
|
332
|
+
running.delete(goal.title);
|
|
333
|
+
currentGoal = null;
|
|
334
|
+
const progressStr = `${completedCount}/${completedCount + queue.length}`;
|
|
335
|
+
await updateState(config, {
|
|
336
|
+
lastGoalCompleted: goal.title,
|
|
337
|
+
progress: progressStr,
|
|
338
|
+
currentGoal: undefined,
|
|
339
|
+
});
|
|
340
|
+
logger.info({ goal: goal.title, progress: progressStr }, `Completed goal: ${goal.title}`);
|
|
341
|
+
wake();
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
await Promise.all(Array.from({ length: numWorkers }, () => runWorker()));
|
|
345
|
+
goalsWatcher.close();
|
|
346
|
+
if (heartbeatTimer) {
|
|
347
|
+
clearInterval(heartbeatTimer);
|
|
348
|
+
}
|
|
349
|
+
if (!shuttingDown) {
|
|
350
|
+
logger.info('All goals processed');
|
|
351
|
+
}
|
|
352
|
+
if (ngrokClose) {
|
|
353
|
+
await ngrokClose();
|
|
354
|
+
}
|
|
355
|
+
if (statusServerClose) {
|
|
356
|
+
await statusServerClose();
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
export function registerShutdownHandlers(logger) {
|
|
360
|
+
let signalCount = 0;
|
|
361
|
+
const handler = (signal) => {
|
|
362
|
+
signalCount++;
|
|
363
|
+
if (signalCount === 1) {
|
|
364
|
+
logger.info({ signal }, 'Shutting down gracefully...');
|
|
365
|
+
shuttingDown = true;
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
logger.warn({ signal }, 'Forced shutdown');
|
|
369
|
+
process.exit(1);
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
process.on('SIGINT', () => handler('SIGINT'));
|
|
373
|
+
process.on('SIGTERM', () => handler('SIGTERM'));
|
|
374
|
+
}
|
package/dist/git.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** One commit in the feed for dashboard/API. */
|
|
2
|
+
export interface CommitEntry {
|
|
3
|
+
hash: string;
|
|
4
|
+
message: string;
|
|
5
|
+
timestamp: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Returns true if git working tree has no uncommitted changes (clean).
|
|
9
|
+
* Runs `git status --porcelain` in workspaceRoot; empty output = clean.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isWorkingTreeClean(workspaceRoot: string, options?: {
|
|
12
|
+
ignorePaths?: string[];
|
|
13
|
+
}): Promise<boolean>;
|
|
14
|
+
/**
|
|
15
|
+
* Creates a checkpoint commit: git add -A && git commit -m message.
|
|
16
|
+
* Use when autoCheckpoint is true and tree is dirty before execute-plan.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createCheckpoint(workspaceRoot: string, message: string): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Returns the last N commits (hash, message, timestamp) for dashboard/API.
|
|
21
|
+
* Uses simple-git. Returns [] on error or not a git repo.
|
|
22
|
+
*/
|
|
23
|
+
export declare function getRecentCommits(workspaceRoot: string, limit?: number): Promise<CommitEntry[]>;
|