@supaku/agentfactory 0.1.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/LICENSE +21 -0
- package/dist/src/deployment/deployment-checker.d.ts +110 -0
- package/dist/src/deployment/deployment-checker.d.ts.map +1 -0
- package/dist/src/deployment/deployment-checker.js +242 -0
- package/dist/src/deployment/index.d.ts +3 -0
- package/dist/src/deployment/index.d.ts.map +1 -0
- package/dist/src/deployment/index.js +2 -0
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +4 -0
- package/dist/src/logger.d.ts +117 -0
- package/dist/src/logger.d.ts.map +1 -0
- package/dist/src/logger.js +430 -0
- package/dist/src/orchestrator/activity-emitter.d.ts +128 -0
- package/dist/src/orchestrator/activity-emitter.d.ts.map +1 -0
- package/dist/src/orchestrator/activity-emitter.js +406 -0
- package/dist/src/orchestrator/api-activity-emitter.d.ts +167 -0
- package/dist/src/orchestrator/api-activity-emitter.d.ts.map +1 -0
- package/dist/src/orchestrator/api-activity-emitter.js +469 -0
- package/dist/src/orchestrator/heartbeat-writer.d.ts +57 -0
- package/dist/src/orchestrator/heartbeat-writer.d.ts.map +1 -0
- package/dist/src/orchestrator/heartbeat-writer.js +137 -0
- package/dist/src/orchestrator/index.d.ts +20 -0
- package/dist/src/orchestrator/index.d.ts.map +1 -0
- package/dist/src/orchestrator/index.js +22 -0
- package/dist/src/orchestrator/log-analyzer.d.ts +160 -0
- package/dist/src/orchestrator/log-analyzer.d.ts.map +1 -0
- package/dist/src/orchestrator/log-analyzer.js +572 -0
- package/dist/src/orchestrator/log-config.d.ts +39 -0
- package/dist/src/orchestrator/log-config.d.ts.map +1 -0
- package/dist/src/orchestrator/log-config.js +45 -0
- package/dist/src/orchestrator/orchestrator.d.ts +246 -0
- package/dist/src/orchestrator/orchestrator.d.ts.map +1 -0
- package/dist/src/orchestrator/orchestrator.js +2525 -0
- package/dist/src/orchestrator/parse-work-result.d.ts +16 -0
- package/dist/src/orchestrator/parse-work-result.d.ts.map +1 -0
- package/dist/src/orchestrator/parse-work-result.js +73 -0
- package/dist/src/orchestrator/progress-logger.d.ts +72 -0
- package/dist/src/orchestrator/progress-logger.d.ts.map +1 -0
- package/dist/src/orchestrator/progress-logger.js +135 -0
- package/dist/src/orchestrator/session-logger.d.ts +159 -0
- package/dist/src/orchestrator/session-logger.d.ts.map +1 -0
- package/dist/src/orchestrator/session-logger.js +275 -0
- package/dist/src/orchestrator/state-recovery.d.ts +96 -0
- package/dist/src/orchestrator/state-recovery.d.ts.map +1 -0
- package/dist/src/orchestrator/state-recovery.js +301 -0
- package/dist/src/orchestrator/state-types.d.ts +165 -0
- package/dist/src/orchestrator/state-types.d.ts.map +1 -0
- package/dist/src/orchestrator/state-types.js +7 -0
- package/dist/src/orchestrator/stream-parser.d.ts +145 -0
- package/dist/src/orchestrator/stream-parser.d.ts.map +1 -0
- package/dist/src/orchestrator/stream-parser.js +131 -0
- package/dist/src/orchestrator/types.d.ts +205 -0
- package/dist/src/orchestrator/types.d.ts.map +1 -0
- package/dist/src/orchestrator/types.js +4 -0
- package/dist/src/providers/amp-provider.d.ts +20 -0
- package/dist/src/providers/amp-provider.d.ts.map +1 -0
- package/dist/src/providers/amp-provider.js +24 -0
- package/dist/src/providers/claude-provider.d.ts +18 -0
- package/dist/src/providers/claude-provider.d.ts.map +1 -0
- package/dist/src/providers/claude-provider.js +267 -0
- package/dist/src/providers/codex-provider.d.ts +21 -0
- package/dist/src/providers/codex-provider.d.ts.map +1 -0
- package/dist/src/providers/codex-provider.js +25 -0
- package/dist/src/providers/index.d.ts +42 -0
- package/dist/src/providers/index.d.ts.map +1 -0
- package/dist/src/providers/index.js +77 -0
- package/dist/src/providers/types.d.ts +147 -0
- package/dist/src/providers/types.d.ts.map +1 -0
- package/dist/src/providers/types.js +13 -0
- package/package.json +63 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Logger
|
|
3
|
+
*
|
|
4
|
+
* Verbose logging of agent sessions for analysis and improvement.
|
|
5
|
+
* Uses JSON Lines format for append efficiency and easy parsing.
|
|
6
|
+
*
|
|
7
|
+
* Logs are stored at: .agent-logs/sessions/{session-id}/
|
|
8
|
+
* - metadata.json: Session metadata
|
|
9
|
+
* - events.jsonl: Event log (JSON Lines)
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, } from 'fs';
|
|
12
|
+
import { resolve } from 'path';
|
|
13
|
+
/**
|
|
14
|
+
* SessionLogger handles verbose logging of agent sessions
|
|
15
|
+
*/
|
|
16
|
+
export class SessionLogger {
|
|
17
|
+
config;
|
|
18
|
+
sessionDir;
|
|
19
|
+
metadataPath;
|
|
20
|
+
eventsPath;
|
|
21
|
+
metadata;
|
|
22
|
+
stopped = false;
|
|
23
|
+
constructor(config) {
|
|
24
|
+
this.config = {
|
|
25
|
+
...config,
|
|
26
|
+
logsDir: config.logsDir ?? '.agent-logs',
|
|
27
|
+
};
|
|
28
|
+
// Create session directory path
|
|
29
|
+
this.sessionDir = resolve(this.config.logsDir, 'sessions', this.config.sessionId);
|
|
30
|
+
this.metadataPath = resolve(this.sessionDir, 'metadata.json');
|
|
31
|
+
this.eventsPath = resolve(this.sessionDir, 'events.jsonl');
|
|
32
|
+
// Initialize metadata
|
|
33
|
+
this.metadata = {
|
|
34
|
+
sessionId: this.config.sessionId,
|
|
35
|
+
issueId: this.config.issueId,
|
|
36
|
+
issueIdentifier: this.config.issueIdentifier,
|
|
37
|
+
workType: this.config.workType,
|
|
38
|
+
prompt: this.config.prompt,
|
|
39
|
+
startedAt: Date.now(),
|
|
40
|
+
status: 'running',
|
|
41
|
+
toolCallsCount: 0,
|
|
42
|
+
errorsCount: 0,
|
|
43
|
+
workerId: this.config.workerId,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Initialize the logger - create directory and write initial metadata
|
|
48
|
+
*/
|
|
49
|
+
initialize() {
|
|
50
|
+
try {
|
|
51
|
+
// Create directory
|
|
52
|
+
if (!existsSync(this.sessionDir)) {
|
|
53
|
+
mkdirSync(this.sessionDir, { recursive: true });
|
|
54
|
+
}
|
|
55
|
+
// Write initial metadata
|
|
56
|
+
this.writeMetadata();
|
|
57
|
+
// Log init event
|
|
58
|
+
this.logEvent({
|
|
59
|
+
timestamp: Date.now(),
|
|
60
|
+
type: 'init',
|
|
61
|
+
content: {
|
|
62
|
+
issueIdentifier: this.config.issueIdentifier,
|
|
63
|
+
workType: this.config.workType,
|
|
64
|
+
promptPreview: this.config.prompt.substring(0, 200),
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
// Silently fail - logging is best-effort
|
|
70
|
+
console.warn('SessionLogger: Failed to initialize:', error);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Log a generic event
|
|
75
|
+
*/
|
|
76
|
+
logEvent(event) {
|
|
77
|
+
if (this.stopped)
|
|
78
|
+
return;
|
|
79
|
+
try {
|
|
80
|
+
const line = JSON.stringify(event) + '\n';
|
|
81
|
+
appendFileSync(this.eventsPath, line);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Silently fail
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Log a tool use event
|
|
89
|
+
*/
|
|
90
|
+
logToolUse(toolName, input) {
|
|
91
|
+
this.metadata.toolCallsCount++;
|
|
92
|
+
this.logEvent({
|
|
93
|
+
timestamp: Date.now(),
|
|
94
|
+
type: 'tool_use',
|
|
95
|
+
tool: toolName,
|
|
96
|
+
content: {
|
|
97
|
+
tool: toolName,
|
|
98
|
+
input: input ? JSON.stringify(input).substring(0, 2000) : undefined,
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Log a tool result event
|
|
104
|
+
*/
|
|
105
|
+
logToolResult(toolName, result, isError = false) {
|
|
106
|
+
if (isError) {
|
|
107
|
+
this.metadata.errorsCount++;
|
|
108
|
+
}
|
|
109
|
+
this.logEvent({
|
|
110
|
+
timestamp: Date.now(),
|
|
111
|
+
type: 'tool_result',
|
|
112
|
+
tool: toolName,
|
|
113
|
+
content: {
|
|
114
|
+
tool: toolName,
|
|
115
|
+
result: typeof result === 'string' ? result.substring(0, 2000) : JSON.stringify(result).substring(0, 2000),
|
|
116
|
+
},
|
|
117
|
+
isError,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Log an assistant message/thought
|
|
122
|
+
*/
|
|
123
|
+
logAssistant(content) {
|
|
124
|
+
this.logEvent({
|
|
125
|
+
timestamp: Date.now(),
|
|
126
|
+
type: 'assistant',
|
|
127
|
+
content: content.substring(0, 2000),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Log an error
|
|
132
|
+
*/
|
|
133
|
+
logError(message, error, metadata) {
|
|
134
|
+
this.metadata.errorsCount++;
|
|
135
|
+
this.logEvent({
|
|
136
|
+
timestamp: Date.now(),
|
|
137
|
+
type: 'error',
|
|
138
|
+
content: {
|
|
139
|
+
message,
|
|
140
|
+
error: error instanceof Error ? error.message : String(error),
|
|
141
|
+
stack: error instanceof Error ? error.stack?.substring(0, 500) : undefined,
|
|
142
|
+
},
|
|
143
|
+
isError: true,
|
|
144
|
+
metadata,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Log a warning (non-fatal issue)
|
|
149
|
+
*/
|
|
150
|
+
logWarning(message, details) {
|
|
151
|
+
this.logEvent({
|
|
152
|
+
timestamp: Date.now(),
|
|
153
|
+
type: 'warning',
|
|
154
|
+
content: {
|
|
155
|
+
message,
|
|
156
|
+
...details,
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Log a status change
|
|
162
|
+
*/
|
|
163
|
+
logStatus(status, details) {
|
|
164
|
+
this.logEvent({
|
|
165
|
+
timestamp: Date.now(),
|
|
166
|
+
type: 'status',
|
|
167
|
+
content: {
|
|
168
|
+
status,
|
|
169
|
+
...details,
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Finalize the session with a final status
|
|
175
|
+
*/
|
|
176
|
+
finalize(status, options) {
|
|
177
|
+
if (this.stopped)
|
|
178
|
+
return;
|
|
179
|
+
this.stopped = true;
|
|
180
|
+
const now = Date.now();
|
|
181
|
+
// Update metadata
|
|
182
|
+
this.metadata.endedAt = now;
|
|
183
|
+
this.metadata.status = status;
|
|
184
|
+
if (options?.errorMessage) {
|
|
185
|
+
this.metadata.errorMessage = options.errorMessage;
|
|
186
|
+
}
|
|
187
|
+
if (options?.pullRequestUrl) {
|
|
188
|
+
this.metadata.pullRequestUrl = options.pullRequestUrl;
|
|
189
|
+
}
|
|
190
|
+
// Log completion event
|
|
191
|
+
this.logEvent({
|
|
192
|
+
timestamp: now,
|
|
193
|
+
type: status === 'completed' ? 'complete' : 'stop',
|
|
194
|
+
content: {
|
|
195
|
+
status,
|
|
196
|
+
duration: now - this.metadata.startedAt,
|
|
197
|
+
toolCallsCount: this.metadata.toolCallsCount,
|
|
198
|
+
errorsCount: this.metadata.errorsCount,
|
|
199
|
+
...options,
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
// Write final metadata
|
|
203
|
+
this.writeMetadata();
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Get the session directory path
|
|
207
|
+
*/
|
|
208
|
+
getSessionDir() {
|
|
209
|
+
return this.sessionDir;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Get current metadata
|
|
213
|
+
*/
|
|
214
|
+
getMetadata() {
|
|
215
|
+
return { ...this.metadata };
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Write metadata to file
|
|
219
|
+
*/
|
|
220
|
+
writeMetadata() {
|
|
221
|
+
try {
|
|
222
|
+
writeFileSync(this.metadataPath, JSON.stringify(this.metadata, null, 2));
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// Silently fail
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Create and initialize a session logger
|
|
231
|
+
*/
|
|
232
|
+
export function createSessionLogger(config) {
|
|
233
|
+
const logger = new SessionLogger(config);
|
|
234
|
+
logger.initialize();
|
|
235
|
+
return logger;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Read session metadata from a session directory
|
|
239
|
+
*/
|
|
240
|
+
export function readSessionMetadata(sessionDir) {
|
|
241
|
+
const metadataPath = resolve(sessionDir, 'metadata.json');
|
|
242
|
+
try {
|
|
243
|
+
if (!existsSync(metadataPath))
|
|
244
|
+
return null;
|
|
245
|
+
const content = readFileSync(metadataPath, 'utf-8');
|
|
246
|
+
return JSON.parse(content);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Read events from a session directory
|
|
254
|
+
* Returns an async generator for memory efficiency
|
|
255
|
+
*/
|
|
256
|
+
export function* readSessionEvents(sessionDir) {
|
|
257
|
+
const eventsPath = resolve(sessionDir, 'events.jsonl');
|
|
258
|
+
try {
|
|
259
|
+
if (!existsSync(eventsPath))
|
|
260
|
+
return;
|
|
261
|
+
const content = readFileSync(eventsPath, 'utf-8');
|
|
262
|
+
const lines = content.split('\n').filter((line) => line.trim());
|
|
263
|
+
for (const line of lines) {
|
|
264
|
+
try {
|
|
265
|
+
yield JSON.parse(line);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// Skip invalid lines
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
// Return empty if file doesn't exist or can't be read
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State Recovery
|
|
3
|
+
*
|
|
4
|
+
* Detects and recovers agent state from the .agent/ directory.
|
|
5
|
+
* Enables crash recovery and duplicate agent prevention.
|
|
6
|
+
*/
|
|
7
|
+
import type { WorktreeState, HeartbeatState, TodosState, RecoveryCheckResult } from './state-types';
|
|
8
|
+
import type { AgentWorkType } from '@supaku/agentfactory-linear';
|
|
9
|
+
/**
|
|
10
|
+
* Get the .agent directory path for a worktree
|
|
11
|
+
*/
|
|
12
|
+
export declare function getAgentDir(worktreePath: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Get the path to the state.json file
|
|
15
|
+
*/
|
|
16
|
+
export declare function getStatePath(worktreePath: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Get the path to the heartbeat.json file
|
|
19
|
+
*/
|
|
20
|
+
export declare function getHeartbeatPath(worktreePath: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* Get the path to the todos.json file
|
|
23
|
+
*/
|
|
24
|
+
export declare function getTodosPath(worktreePath: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* Check if a heartbeat is fresh (agent is alive)
|
|
27
|
+
*/
|
|
28
|
+
export declare function isHeartbeatFresh(heartbeat: HeartbeatState | null, timeoutMs?: number): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Read the current state from a worktree
|
|
31
|
+
*/
|
|
32
|
+
export declare function readWorktreeState(worktreePath: string): WorktreeState | null;
|
|
33
|
+
/**
|
|
34
|
+
* Read the current heartbeat from a worktree
|
|
35
|
+
*/
|
|
36
|
+
export declare function readHeartbeat(worktreePath: string): HeartbeatState | null;
|
|
37
|
+
/**
|
|
38
|
+
* Read the current todos from a worktree
|
|
39
|
+
*/
|
|
40
|
+
export declare function readTodos(worktreePath: string): TodosState | null;
|
|
41
|
+
/**
|
|
42
|
+
* Check if recovery is possible for a worktree
|
|
43
|
+
*/
|
|
44
|
+
export declare function checkRecovery(worktreePath: string, options?: {
|
|
45
|
+
heartbeatTimeoutMs?: number;
|
|
46
|
+
maxRecoveryAttempts?: number;
|
|
47
|
+
}): RecoveryCheckResult;
|
|
48
|
+
/**
|
|
49
|
+
* Initialize the .agent directory for a worktree
|
|
50
|
+
*/
|
|
51
|
+
export declare function initializeAgentDir(worktreePath: string): void;
|
|
52
|
+
/**
|
|
53
|
+
* Write the state.json file
|
|
54
|
+
*/
|
|
55
|
+
export declare function writeState(worktreePath: string, state: WorktreeState): void;
|
|
56
|
+
/**
|
|
57
|
+
* Update specific fields in the state
|
|
58
|
+
*/
|
|
59
|
+
export declare function updateState(worktreePath: string, updates: Partial<WorktreeState>): WorktreeState | null;
|
|
60
|
+
/**
|
|
61
|
+
* Write the todos.json file
|
|
62
|
+
*/
|
|
63
|
+
export declare function writeTodos(worktreePath: string, todos: TodosState): void;
|
|
64
|
+
/**
|
|
65
|
+
* Create initial state for a new agent
|
|
66
|
+
*/
|
|
67
|
+
export declare function createInitialState(options: {
|
|
68
|
+
issueId: string;
|
|
69
|
+
issueIdentifier: string;
|
|
70
|
+
linearSessionId: string | null;
|
|
71
|
+
workType: AgentWorkType;
|
|
72
|
+
prompt: string;
|
|
73
|
+
workerId?: string | null;
|
|
74
|
+
pid?: number | null;
|
|
75
|
+
}): WorktreeState;
|
|
76
|
+
/**
|
|
77
|
+
* Generate the task list ID for a worktree (matches orchestrator format)
|
|
78
|
+
*
|
|
79
|
+
* @param issueIdentifier - Issue identifier (e.g., "SUP-123")
|
|
80
|
+
* @param workType - Work type suffix (e.g., "development" -> "DEV")
|
|
81
|
+
* @returns Task list ID (e.g., "SUP-123-DEV")
|
|
82
|
+
*/
|
|
83
|
+
export declare function getTaskListId(issueIdentifier: string, workType: AgentWorkType): string;
|
|
84
|
+
/**
|
|
85
|
+
* Build a recovery prompt for resuming crashed work
|
|
86
|
+
*/
|
|
87
|
+
export declare function buildRecoveryPrompt(state: WorktreeState, todos?: TodosState): string;
|
|
88
|
+
/**
|
|
89
|
+
* Parse environment variable for heartbeat timeout
|
|
90
|
+
*/
|
|
91
|
+
export declare function getHeartbeatTimeoutFromEnv(): number;
|
|
92
|
+
/**
|
|
93
|
+
* Parse environment variable for max recovery attempts
|
|
94
|
+
*/
|
|
95
|
+
export declare function getMaxRecoveryAttemptsFromEnv(): number;
|
|
96
|
+
//# sourceMappingURL=state-recovery.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"state-recovery.d.ts","sourceRoot":"","sources":["../../../src/orchestrator/state-recovery.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,KAAK,EACV,aAAa,EACb,cAAc,EACd,UAAU,EACV,mBAAmB,EAEpB,MAAM,eAAe,CAAA;AACtB,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAA;AAQhE;;GAEG;AACH,wBAAgB,WAAW,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAExD;AAED;;GAEG;AACH,wBAAgB,YAAY,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAEzD;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAE7D;AAED;;GAEG;AACH,wBAAgB,YAAY,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAEzD;AAeD;;GAEG;AACH,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,cAAc,GAAG,IAAI,EAChC,SAAS,GAAE,MAAqC,GAC/C,OAAO,CAIT;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,aAAa,GAAG,IAAI,CAE5E;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,YAAY,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI,CAEzE;AAED;;GAEG;AACH,wBAAgB,SAAS,CAAC,YAAY,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CAEjE;AAED;;GAEG;AACH,wBAAgB,aAAa,CAC3B,YAAY,EAAE,MAAM,EACpB,OAAO,GAAE;IACP,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,mBAAmB,CAAC,EAAE,MAAM,CAAA;CACxB,GACL,mBAAmB,CA2ErB;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAK7D;AAED;;GAEG;AACH,wBAAgB,UAAU,CAAC,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,GAAG,IAAI,CAI3E;AAED;;GAEG;AACH,wBAAgB,WAAW,CACzB,YAAY,EAAE,MAAM,EACpB,OAAO,EAAE,OAAO,CAAC,aAAa,CAAC,GAC9B,aAAa,GAAG,IAAI,CAWtB;AAED;;GAEG;AACH,wBAAgB,UAAU,CAAC,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,GAAG,IAAI,CAIxE;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE;IAC1C,OAAO,EAAE,MAAM,CAAA;IACf,eAAe,EAAE,MAAM,CAAA;IACvB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,QAAQ,EAAE,aAAa,CAAA;IACvB,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACpB,GAAG,aAAa,CAmBhB;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAC3B,eAAe,EAAE,MAAM,EACvB,QAAQ,EAAE,aAAa,GACtB,MAAM,CAcR;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,aAAa,EACpB,KAAK,CAAC,EAAE,UAAU,GACjB,MAAM,CA6CR;AAED;;GAEG;AACH,wBAAgB,0BAA0B,IAAI,MAAM,CASnD;AAED;;GAEG;AACH,wBAAgB,6BAA6B,IAAI,MAAM,CAStD"}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State Recovery
|
|
3
|
+
*
|
|
4
|
+
* Detects and recovers agent state from the .agent/ directory.
|
|
5
|
+
* Enables crash recovery and duplicate agent prevention.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
8
|
+
import { resolve } from 'path';
|
|
9
|
+
// Default heartbeat timeout: 30 seconds
|
|
10
|
+
const DEFAULT_HEARTBEAT_TIMEOUT_MS = 30000;
|
|
11
|
+
// Default max recovery attempts
|
|
12
|
+
const DEFAULT_MAX_RECOVERY_ATTEMPTS = 3;
|
|
13
|
+
/**
|
|
14
|
+
* Get the .agent directory path for a worktree
|
|
15
|
+
*/
|
|
16
|
+
export function getAgentDir(worktreePath) {
|
|
17
|
+
return resolve(worktreePath, '.agent');
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Get the path to the state.json file
|
|
21
|
+
*/
|
|
22
|
+
export function getStatePath(worktreePath) {
|
|
23
|
+
return resolve(getAgentDir(worktreePath), 'state.json');
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Get the path to the heartbeat.json file
|
|
27
|
+
*/
|
|
28
|
+
export function getHeartbeatPath(worktreePath) {
|
|
29
|
+
return resolve(getAgentDir(worktreePath), 'heartbeat.json');
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Get the path to the todos.json file
|
|
33
|
+
*/
|
|
34
|
+
export function getTodosPath(worktreePath) {
|
|
35
|
+
return resolve(getAgentDir(worktreePath), 'todos.json');
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Read and parse a JSON file safely
|
|
39
|
+
*/
|
|
40
|
+
function readJsonSafe(path) {
|
|
41
|
+
try {
|
|
42
|
+
if (!existsSync(path))
|
|
43
|
+
return null;
|
|
44
|
+
const content = readFileSync(path, 'utf-8');
|
|
45
|
+
return JSON.parse(content);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Check if a heartbeat is fresh (agent is alive)
|
|
53
|
+
*/
|
|
54
|
+
export function isHeartbeatFresh(heartbeat, timeoutMs = DEFAULT_HEARTBEAT_TIMEOUT_MS) {
|
|
55
|
+
if (!heartbeat)
|
|
56
|
+
return false;
|
|
57
|
+
const age = Date.now() - heartbeat.timestamp;
|
|
58
|
+
return age < timeoutMs;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Read the current state from a worktree
|
|
62
|
+
*/
|
|
63
|
+
export function readWorktreeState(worktreePath) {
|
|
64
|
+
return readJsonSafe(getStatePath(worktreePath));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Read the current heartbeat from a worktree
|
|
68
|
+
*/
|
|
69
|
+
export function readHeartbeat(worktreePath) {
|
|
70
|
+
return readJsonSafe(getHeartbeatPath(worktreePath));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Read the current todos from a worktree
|
|
74
|
+
*/
|
|
75
|
+
export function readTodos(worktreePath) {
|
|
76
|
+
return readJsonSafe(getTodosPath(worktreePath));
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Check if recovery is possible for a worktree
|
|
80
|
+
*/
|
|
81
|
+
export function checkRecovery(worktreePath, options = {}) {
|
|
82
|
+
const heartbeatTimeoutMs = options.heartbeatTimeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS;
|
|
83
|
+
const maxRecoveryAttempts = options.maxRecoveryAttempts ?? DEFAULT_MAX_RECOVERY_ATTEMPTS;
|
|
84
|
+
const agentDir = getAgentDir(worktreePath);
|
|
85
|
+
// Check if .agent directory exists
|
|
86
|
+
if (!existsSync(agentDir)) {
|
|
87
|
+
return {
|
|
88
|
+
canRecover: false,
|
|
89
|
+
agentAlive: false,
|
|
90
|
+
reason: 'no_state',
|
|
91
|
+
message: 'No .agent directory found in worktree',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
// Read state
|
|
95
|
+
const state = readWorktreeState(worktreePath);
|
|
96
|
+
if (!state) {
|
|
97
|
+
return {
|
|
98
|
+
canRecover: false,
|
|
99
|
+
agentAlive: false,
|
|
100
|
+
reason: 'no_state',
|
|
101
|
+
message: 'No state.json found in .agent directory',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
// Validate state
|
|
105
|
+
if (!state.issueId || !state.issueIdentifier) {
|
|
106
|
+
return {
|
|
107
|
+
canRecover: false,
|
|
108
|
+
agentAlive: false,
|
|
109
|
+
state,
|
|
110
|
+
reason: 'invalid_state',
|
|
111
|
+
message: 'State is missing required fields (issueId, issueIdentifier)',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
// Read heartbeat
|
|
115
|
+
const heartbeat = readHeartbeat(worktreePath);
|
|
116
|
+
// Check if agent is alive
|
|
117
|
+
if (isHeartbeatFresh(heartbeat, heartbeatTimeoutMs)) {
|
|
118
|
+
return {
|
|
119
|
+
canRecover: false,
|
|
120
|
+
agentAlive: true,
|
|
121
|
+
state,
|
|
122
|
+
heartbeat: heartbeat,
|
|
123
|
+
reason: 'agent_alive',
|
|
124
|
+
message: `Agent is still running (PID: ${heartbeat.pid}, last heartbeat: ${new Date(heartbeat.timestamp).toISOString()})`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
// Check recovery attempts
|
|
128
|
+
if (state.recoveryAttempts >= maxRecoveryAttempts) {
|
|
129
|
+
return {
|
|
130
|
+
canRecover: false,
|
|
131
|
+
agentAlive: false,
|
|
132
|
+
state,
|
|
133
|
+
heartbeat: heartbeat ?? undefined,
|
|
134
|
+
reason: 'max_attempts',
|
|
135
|
+
message: `Maximum recovery attempts reached (${state.recoveryAttempts}/${maxRecoveryAttempts})`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// Recovery is possible
|
|
139
|
+
const todos = readTodos(worktreePath);
|
|
140
|
+
return {
|
|
141
|
+
canRecover: true,
|
|
142
|
+
agentAlive: false,
|
|
143
|
+
state,
|
|
144
|
+
heartbeat: heartbeat ?? undefined,
|
|
145
|
+
todos: todos ?? undefined,
|
|
146
|
+
message: `Recovery possible (attempt ${state.recoveryAttempts + 1}/${maxRecoveryAttempts})`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Initialize the .agent directory for a worktree
|
|
151
|
+
*/
|
|
152
|
+
export function initializeAgentDir(worktreePath) {
|
|
153
|
+
const agentDir = getAgentDir(worktreePath);
|
|
154
|
+
if (!existsSync(agentDir)) {
|
|
155
|
+
mkdirSync(agentDir, { recursive: true });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Write the state.json file
|
|
160
|
+
*/
|
|
161
|
+
export function writeState(worktreePath, state) {
|
|
162
|
+
const statePath = getStatePath(worktreePath);
|
|
163
|
+
initializeAgentDir(worktreePath);
|
|
164
|
+
writeFileSync(statePath, JSON.stringify(state, null, 2));
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Update specific fields in the state
|
|
168
|
+
*/
|
|
169
|
+
export function updateState(worktreePath, updates) {
|
|
170
|
+
const current = readWorktreeState(worktreePath);
|
|
171
|
+
if (!current)
|
|
172
|
+
return null;
|
|
173
|
+
const updated = {
|
|
174
|
+
...current,
|
|
175
|
+
...updates,
|
|
176
|
+
lastUpdatedAt: Date.now(),
|
|
177
|
+
};
|
|
178
|
+
writeState(worktreePath, updated);
|
|
179
|
+
return updated;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Write the todos.json file
|
|
183
|
+
*/
|
|
184
|
+
export function writeTodos(worktreePath, todos) {
|
|
185
|
+
const todosPath = getTodosPath(worktreePath);
|
|
186
|
+
initializeAgentDir(worktreePath);
|
|
187
|
+
writeFileSync(todosPath, JSON.stringify(todos, null, 2));
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Create initial state for a new agent
|
|
191
|
+
*/
|
|
192
|
+
export function createInitialState(options) {
|
|
193
|
+
const now = Date.now();
|
|
194
|
+
const taskListId = getTaskListId(options.issueIdentifier, options.workType);
|
|
195
|
+
return {
|
|
196
|
+
issueId: options.issueId,
|
|
197
|
+
issueIdentifier: options.issueIdentifier,
|
|
198
|
+
linearSessionId: options.linearSessionId,
|
|
199
|
+
claudeSessionId: null,
|
|
200
|
+
workType: options.workType,
|
|
201
|
+
prompt: options.prompt,
|
|
202
|
+
startedAt: now,
|
|
203
|
+
status: 'initializing',
|
|
204
|
+
currentPhase: null,
|
|
205
|
+
lastUpdatedAt: now,
|
|
206
|
+
recoveryAttempts: 0,
|
|
207
|
+
workerId: options.workerId ?? null,
|
|
208
|
+
pid: options.pid ?? null,
|
|
209
|
+
taskListId,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Generate the task list ID for a worktree (matches orchestrator format)
|
|
214
|
+
*
|
|
215
|
+
* @param issueIdentifier - Issue identifier (e.g., "SUP-123")
|
|
216
|
+
* @param workType - Work type suffix (e.g., "development" -> "DEV")
|
|
217
|
+
* @returns Task list ID (e.g., "SUP-123-DEV")
|
|
218
|
+
*/
|
|
219
|
+
export function getTaskListId(issueIdentifier, workType) {
|
|
220
|
+
const suffixMap = {
|
|
221
|
+
research: 'RES',
|
|
222
|
+
'backlog-creation': 'BC',
|
|
223
|
+
development: 'DEV',
|
|
224
|
+
inflight: 'INF',
|
|
225
|
+
coordination: 'COORD',
|
|
226
|
+
qa: 'QA',
|
|
227
|
+
acceptance: 'AC',
|
|
228
|
+
refinement: 'REF',
|
|
229
|
+
'qa-coordination': 'QA-COORD',
|
|
230
|
+
'acceptance-coordination': 'AC-COORD',
|
|
231
|
+
};
|
|
232
|
+
return `${issueIdentifier}-${suffixMap[workType]}`;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Build a recovery prompt for resuming crashed work
|
|
236
|
+
*/
|
|
237
|
+
export function buildRecoveryPrompt(state, todos) {
|
|
238
|
+
const lines = [];
|
|
239
|
+
lines.push(`Resume work on ${state.issueIdentifier}.`);
|
|
240
|
+
lines.push('');
|
|
241
|
+
lines.push('RECOVERY CONTEXT:');
|
|
242
|
+
lines.push(`- Previous work type: ${state.workType}`);
|
|
243
|
+
lines.push(`- Last status: ${state.status}`);
|
|
244
|
+
if (state.currentPhase) {
|
|
245
|
+
lines.push(`- Last phase: ${state.currentPhase}`);
|
|
246
|
+
}
|
|
247
|
+
lines.push(`- Recovery attempt: ${state.recoveryAttempts + 1}`);
|
|
248
|
+
// Include task list ID for Claude Code Tasks integration
|
|
249
|
+
const taskListId = getTaskListId(state.issueIdentifier, state.workType);
|
|
250
|
+
lines.push(`- Task list ID: ${taskListId}`);
|
|
251
|
+
lines.push('');
|
|
252
|
+
// Note about Claude Code Tasks persistence
|
|
253
|
+
lines.push('TASK STATE:');
|
|
254
|
+
lines.push(`Your task list is preserved at: ~/.claude/tasks/${taskListId}/`);
|
|
255
|
+
lines.push('Use TaskList to see the current state of pending/completed tasks.');
|
|
256
|
+
lines.push('');
|
|
257
|
+
if (todos && todos.items.length > 0) {
|
|
258
|
+
lines.push('PREVIOUS TODO LIST (legacy):');
|
|
259
|
+
for (const item of todos.items) {
|
|
260
|
+
const statusIcon = item.status === 'completed' ? '\u2713' :
|
|
261
|
+
item.status === 'in_progress' ? '\u2192' : '\u25CB';
|
|
262
|
+
lines.push(` ${statusIcon} [${item.status}] ${item.content}`);
|
|
263
|
+
}
|
|
264
|
+
lines.push('');
|
|
265
|
+
}
|
|
266
|
+
lines.push('INSTRUCTIONS:');
|
|
267
|
+
lines.push('1. Run TaskList to see any pending tasks from the previous session');
|
|
268
|
+
lines.push('2. Check git status to see what has been done');
|
|
269
|
+
lines.push('3. Review the codebase for any partial changes');
|
|
270
|
+
lines.push('4. Continue from where the previous session left off');
|
|
271
|
+
lines.push('5. If work appears complete, verify and create PR if needed');
|
|
272
|
+
lines.push('');
|
|
273
|
+
lines.push(`Original prompt: ${state.prompt}`);
|
|
274
|
+
return lines.join('\n');
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Parse environment variable for heartbeat timeout
|
|
278
|
+
*/
|
|
279
|
+
export function getHeartbeatTimeoutFromEnv() {
|
|
280
|
+
const envValue = process.env.AGENT_HEARTBEAT_TIMEOUT_MS;
|
|
281
|
+
if (envValue) {
|
|
282
|
+
const parsed = parseInt(envValue, 10);
|
|
283
|
+
if (!isNaN(parsed) && parsed > 0) {
|
|
284
|
+
return parsed;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return DEFAULT_HEARTBEAT_TIMEOUT_MS;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Parse environment variable for max recovery attempts
|
|
291
|
+
*/
|
|
292
|
+
export function getMaxRecoveryAttemptsFromEnv() {
|
|
293
|
+
const envValue = process.env.AGENT_MAX_RECOVERY_ATTEMPTS;
|
|
294
|
+
if (envValue) {
|
|
295
|
+
const parsed = parseInt(envValue, 10);
|
|
296
|
+
if (!isNaN(parsed) && parsed > 0) {
|
|
297
|
+
return parsed;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return DEFAULT_MAX_RECOVERY_ATTEMPTS;
|
|
301
|
+
}
|