mcp-codex-worker 0.1.11 → 0.1.13
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/dist/src/execution/base-adapter.d.ts +63 -0
- package/dist/src/execution/base-adapter.js +74 -0
- package/dist/src/execution/base-adapter.js.map +1 -0
- package/dist/src/execution/claude-adapter.d.ts +12 -0
- package/dist/src/execution/claude-adapter.js +23 -0
- package/dist/src/execution/claude-adapter.js.map +1 -0
- package/dist/src/execution/codex-adapter.d.ts +21 -0
- package/dist/src/execution/codex-adapter.js +35 -0
- package/dist/src/execution/codex-adapter.js.map +1 -0
- package/dist/src/execution/codex-pause-flow.d.ts +23 -0
- package/dist/src/execution/codex-pause-flow.js +185 -0
- package/dist/src/execution/codex-pause-flow.js.map +1 -0
- package/dist/src/execution/copilot-adapter.d.ts +12 -0
- package/dist/src/execution/copilot-adapter.js +23 -0
- package/dist/src/execution/copilot-adapter.js.map +1 -0
- package/dist/src/execution/provider-capabilities.d.ts +35 -0
- package/dist/src/execution/provider-capabilities.js +58 -0
- package/dist/src/execution/provider-capabilities.js.map +1 -0
- package/dist/src/execution/provider-registry.d.ts +37 -0
- package/dist/src/execution/provider-registry.js +69 -0
- package/dist/src/execution/provider-registry.js.map +1 -0
- package/dist/src/mcp/resource-renderers.d.ts +25 -0
- package/dist/src/mcp/resource-renderers.js +190 -0
- package/dist/src/mcp/resource-renderers.js.map +1 -0
- package/dist/src/mcp/sep1686-handlers.d.ts +56 -0
- package/dist/src/mcp/sep1686-handlers.js +98 -0
- package/dist/src/mcp/sep1686-handlers.js.map +1 -0
- package/dist/src/mcp/tool-definitions.d.ts +157 -0
- package/dist/src/mcp/tool-definitions.js +242 -0
- package/dist/src/mcp/tool-definitions.js.map +1 -1
- package/dist/src/task/fsm-transitions.d.ts +17 -0
- package/dist/src/task/fsm-transitions.js +66 -0
- package/dist/src/task/fsm-transitions.js.map +1 -0
- package/dist/src/task/task-handle-impl.d.ts +10 -0
- package/dist/src/task/task-handle-impl.js +139 -0
- package/dist/src/task/task-handle-impl.js.map +1 -0
- package/dist/src/task/task-handle.d.ts +88 -0
- package/dist/src/task/task-handle.js +2 -0
- package/dist/src/task/task-handle.js.map +1 -0
- package/dist/src/task/task-manager.d.ts +99 -0
- package/dist/src/task/task-manager.js +246 -0
- package/dist/src/task/task-manager.js.map +1 -0
- package/dist/src/task/task-persistence.d.ts +18 -0
- package/dist/src/task/task-persistence.js +61 -0
- package/dist/src/task/task-persistence.js.map +1 -0
- package/dist/src/task/task-state.d.ts +79 -0
- package/dist/src/task/task-state.js +24 -0
- package/dist/src/task/task-state.js.map +1 -0
- package/dist/src/task/task-store.d.ts +46 -0
- package/dist/src/task/task-store.js +104 -0
- package/dist/src/task/task-store.js.map +1 -0
- package/dist/src/task/wire-state-mapper.d.ts +21 -0
- package/dist/src/task/wire-state-mapper.js +63 -0
- package/dist/src/task/wire-state-mapper.js.map +1 -0
- package/package.json +2 -1
- package/src/execution/base-adapter.ts +133 -0
- package/src/execution/claude-adapter.ts +40 -0
- package/src/execution/codex-adapter.ts +67 -0
- package/src/execution/codex-pause-flow.ts +225 -0
- package/src/execution/copilot-adapter.ts +40 -0
- package/src/execution/provider-capabilities.ts +100 -0
- package/src/execution/provider-registry.ts +81 -0
- package/src/mcp/resource-renderers.ts +224 -0
- package/src/mcp/sep1686-handlers.ts +149 -0
- package/src/mcp/tool-definitions.ts +255 -0
- package/src/task/fsm-transitions.ts +72 -0
- package/src/task/task-handle-impl.ts +170 -0
- package/src/task/task-handle.ts +135 -0
- package/src/task/task-manager.ts +328 -0
- package/src/task/task-persistence.ts +95 -0
- package/src/task/task-state.ts +121 -0
- package/src/task/task-store.ts +121 -0
- package/src/task/wire-state-mapper.ts +77 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import {
|
|
2
|
+
uniqueNamesGenerator,
|
|
3
|
+
adjectives,
|
|
4
|
+
animals,
|
|
5
|
+
} from 'unique-names-generator';
|
|
6
|
+
|
|
7
|
+
import { TaskStatus, isTerminalStatus } from './task-state.js';
|
|
8
|
+
import type {
|
|
9
|
+
TaskState,
|
|
10
|
+
TaskTypeName,
|
|
11
|
+
Provider,
|
|
12
|
+
PendingQuestion,
|
|
13
|
+
} from './task-state.js';
|
|
14
|
+
import { TaskStore } from './task-store.js';
|
|
15
|
+
import type { TaskHandle } from './task-handle.js';
|
|
16
|
+
import type { TaskResult, SessionMetrics } from './task-handle.js';
|
|
17
|
+
import { createTaskHandle } from './task-handle-impl.js';
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Configuration
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/** Maximum lines retained in the in-memory output ring buffer. */
|
|
24
|
+
const OUTPUT_RING_BUFFER_MAX = 500;
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Public types
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export interface TaskManagerOptions {
|
|
31
|
+
/**
|
|
32
|
+
* Root directory for persistence files.
|
|
33
|
+
* `undefined` means persistence is disabled (in-memory only).
|
|
34
|
+
*/
|
|
35
|
+
persistenceRoot: string | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CreateTaskInput {
|
|
39
|
+
prompt: string;
|
|
40
|
+
cwd: string;
|
|
41
|
+
provider: Provider;
|
|
42
|
+
taskType: TaskTypeName;
|
|
43
|
+
model?: string;
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
dependsOn?: string[];
|
|
46
|
+
labels?: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Event listener types
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
export type StatusChangeListener = (
|
|
54
|
+
task: TaskState,
|
|
55
|
+
previousStatus: TaskStatus,
|
|
56
|
+
) => void;
|
|
57
|
+
|
|
58
|
+
export type OutputListener = (taskId: string, line: string) => void;
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// TaskManager
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Central orchestrator for task lifecycle management.
|
|
66
|
+
*
|
|
67
|
+
* Wraps {@link TaskStore} with:
|
|
68
|
+
* - Human-readable ID generation (adjective-animal-number)
|
|
69
|
+
* - {@link TaskHandle} creation for provider adapters
|
|
70
|
+
* - Event bus (status changes, output lines)
|
|
71
|
+
* - Output ring buffer capping
|
|
72
|
+
*
|
|
73
|
+
* Spec reference: §3.1 (layer model), §3.2 (TaskHandle)
|
|
74
|
+
*/
|
|
75
|
+
export class TaskManager {
|
|
76
|
+
private readonly store = new TaskStore();
|
|
77
|
+
private readonly handles = new Map<string, TaskHandle>();
|
|
78
|
+
private readonly abortControllers = new Map<string, AbortController>();
|
|
79
|
+
private readonly abortListeners = new Map<string, Set<() => void>>();
|
|
80
|
+
|
|
81
|
+
private readonly statusListeners = new Set<StatusChangeListener>();
|
|
82
|
+
private readonly outputListeners = new Set<OutputListener>();
|
|
83
|
+
|
|
84
|
+
readonly persistenceRoot: string | undefined;
|
|
85
|
+
|
|
86
|
+
constructor(options: TaskManagerOptions) {
|
|
87
|
+
this.persistenceRoot = options.persistenceRoot;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// -------------------------------------------------------------------------
|
|
91
|
+
// Task creation
|
|
92
|
+
// -------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Create a new task with a human-readable ID, store it, and return
|
|
96
|
+
* the initial {@link TaskState}.
|
|
97
|
+
*/
|
|
98
|
+
createTask(input: CreateTaskInput): TaskState {
|
|
99
|
+
const id = this.generateId();
|
|
100
|
+
const now = new Date().toISOString();
|
|
101
|
+
|
|
102
|
+
const task: TaskState = {
|
|
103
|
+
id,
|
|
104
|
+
status: TaskStatus.PENDING,
|
|
105
|
+
provider: input.provider,
|
|
106
|
+
taskType: input.taskType,
|
|
107
|
+
prompt: input.prompt,
|
|
108
|
+
cwd: input.cwd,
|
|
109
|
+
createdAt: now,
|
|
110
|
+
updatedAt: now,
|
|
111
|
+
labels: input.labels ?? [],
|
|
112
|
+
output: [],
|
|
113
|
+
pendingQuestions: [],
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Only set optional properties when defined (exactOptionalPropertyTypes)
|
|
117
|
+
if (input.model !== undefined) task.model = input.model;
|
|
118
|
+
if (input.timeoutMs !== undefined) task.timeoutMs = input.timeoutMs;
|
|
119
|
+
if (input.dependsOn !== undefined) task.dependsOn = input.dependsOn;
|
|
120
|
+
|
|
121
|
+
this.store.create(task);
|
|
122
|
+
|
|
123
|
+
// Pre-create the handle so getHandle never returns undefined for a known task
|
|
124
|
+
const handle = createTaskHandle(this, id);
|
|
125
|
+
this.handles.set(id, handle);
|
|
126
|
+
|
|
127
|
+
return task;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// -------------------------------------------------------------------------
|
|
131
|
+
// Lookups
|
|
132
|
+
// -------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
getTask(id: string): TaskState | undefined {
|
|
135
|
+
return this.store.get(id);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
getBySessionId(sessionId: string): TaskState | undefined {
|
|
139
|
+
return this.store.getBySessionId(sessionId);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
getHandle(id: string): TaskHandle | undefined {
|
|
143
|
+
return this.handles.get(id);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
getAllTasks(): TaskState[] {
|
|
147
|
+
return this.store.getAll();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// -------------------------------------------------------------------------
|
|
151
|
+
// Mutations (called by TaskHandle implementation)
|
|
152
|
+
// -------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Apply a partial update to a task.
|
|
156
|
+
*
|
|
157
|
+
* If the update includes a status change, the FSM is validated by the store.
|
|
158
|
+
* On successful status change the event bus is notified.
|
|
159
|
+
*/
|
|
160
|
+
updateTask(id: string, updates: Partial<TaskState>): TaskState | undefined {
|
|
161
|
+
const prev = this.store.get(id);
|
|
162
|
+
if (!prev) return undefined;
|
|
163
|
+
|
|
164
|
+
const previousStatus = prev.status;
|
|
165
|
+
const result = this.store.update(id, updates);
|
|
166
|
+
|
|
167
|
+
if (result && result.status !== previousStatus) {
|
|
168
|
+
this.emitStatusChange(result, previousStatus);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Append a line to the task's in-memory output ring buffer.
|
|
176
|
+
* Caps at {@link OUTPUT_RING_BUFFER_MAX} lines (oldest lines evicted).
|
|
177
|
+
*/
|
|
178
|
+
appendOutput(id: string, line: string): void {
|
|
179
|
+
const task = this.store.get(id);
|
|
180
|
+
if (!task) return;
|
|
181
|
+
|
|
182
|
+
task.output.push(line);
|
|
183
|
+
if (task.output.length > OUTPUT_RING_BUFFER_MAX) {
|
|
184
|
+
task.output.splice(0, task.output.length - OUTPUT_RING_BUFFER_MAX);
|
|
185
|
+
}
|
|
186
|
+
task.lastOutputAt = new Date().toISOString();
|
|
187
|
+
task.updatedAt = task.lastOutputAt;
|
|
188
|
+
|
|
189
|
+
for (const listener of this.outputListeners) {
|
|
190
|
+
listener(id, line);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Record a verbose-only output line.
|
|
196
|
+
* Updates lastOutputAt and fires the output listener with a prefix,
|
|
197
|
+
* but does NOT push to the in-memory ring buffer.
|
|
198
|
+
*/
|
|
199
|
+
appendOutputFileOnly(id: string, line: string): void {
|
|
200
|
+
const task = this.store.get(id);
|
|
201
|
+
if (!task) return;
|
|
202
|
+
|
|
203
|
+
task.lastOutputAt = new Date().toISOString();
|
|
204
|
+
task.updatedAt = task.lastOutputAt;
|
|
205
|
+
|
|
206
|
+
for (const listener of this.outputListeners) {
|
|
207
|
+
listener(id, `[verbose-only] ${line}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// -------------------------------------------------------------------------
|
|
212
|
+
// Abort management
|
|
213
|
+
// -------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
registerAbort(id: string, controller: AbortController): void {
|
|
216
|
+
this.abortControllers.set(id, controller);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
unregisterAbort(id: string): void {
|
|
220
|
+
this.abortControllers.delete(id);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
getAbortController(id: string): AbortController | undefined {
|
|
224
|
+
return this.abortControllers.get(id);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Register a callback for when a task's abort controller fires.
|
|
229
|
+
* Returns an unsubscribe function.
|
|
230
|
+
*/
|
|
231
|
+
onAborted(id: string, cb: () => void): () => void {
|
|
232
|
+
let listeners = this.abortListeners.get(id);
|
|
233
|
+
if (!listeners) {
|
|
234
|
+
listeners = new Set();
|
|
235
|
+
this.abortListeners.set(id, listeners);
|
|
236
|
+
}
|
|
237
|
+
listeners.add(cb);
|
|
238
|
+
return () => {
|
|
239
|
+
listeners!.delete(cb);
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Fire abort listeners for a task. Called when AbortController signals. */
|
|
244
|
+
fireAbortListeners(id: string): void {
|
|
245
|
+
const listeners = this.abortListeners.get(id);
|
|
246
|
+
if (!listeners) return;
|
|
247
|
+
for (const cb of listeners) {
|
|
248
|
+
cb();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// -------------------------------------------------------------------------
|
|
253
|
+
// Pending question queue
|
|
254
|
+
// -------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
queuePendingQuestion(id: string, q: PendingQuestion): void {
|
|
257
|
+
const task = this.store.get(id);
|
|
258
|
+
if (!task) return;
|
|
259
|
+
task.pendingQuestions.push(q);
|
|
260
|
+
task.updatedAt = new Date().toISOString();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
dequeuePendingQuestion(id: string): PendingQuestion | undefined {
|
|
264
|
+
const task = this.store.get(id);
|
|
265
|
+
if (!task) return undefined;
|
|
266
|
+
const q = task.pendingQuestions.shift();
|
|
267
|
+
if (q !== undefined) {
|
|
268
|
+
task.updatedAt = new Date().toISOString();
|
|
269
|
+
}
|
|
270
|
+
return q;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
getPendingQuestions(id: string): readonly PendingQuestion[] {
|
|
274
|
+
const task = this.store.get(id);
|
|
275
|
+
if (!task) return [];
|
|
276
|
+
return task.pendingQuestions;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// -------------------------------------------------------------------------
|
|
280
|
+
// Event bus
|
|
281
|
+
// -------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Subscribe to status change events.
|
|
285
|
+
* Returns an unsubscribe function.
|
|
286
|
+
*/
|
|
287
|
+
onStatusChange(cb: StatusChangeListener): () => void {
|
|
288
|
+
this.statusListeners.add(cb);
|
|
289
|
+
return () => {
|
|
290
|
+
this.statusListeners.delete(cb);
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Subscribe to output line events.
|
|
296
|
+
* Returns an unsubscribe function.
|
|
297
|
+
*/
|
|
298
|
+
onOutput(cb: OutputListener): () => void {
|
|
299
|
+
this.outputListeners.add(cb);
|
|
300
|
+
return () => {
|
|
301
|
+
this.outputListeners.delete(cb);
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Emit a status change event to all listeners.
|
|
307
|
+
* Public so that the TaskHandle implementation can call it directly.
|
|
308
|
+
*/
|
|
309
|
+
emitStatusChange(task: TaskState, previousStatus: TaskStatus): void {
|
|
310
|
+
for (const listener of this.statusListeners) {
|
|
311
|
+
listener(task, previousStatus);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// -------------------------------------------------------------------------
|
|
316
|
+
// Internal helpers
|
|
317
|
+
// -------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
private generateId(): string {
|
|
320
|
+
const name = uniqueNamesGenerator({
|
|
321
|
+
dictionaries: [adjectives, animals],
|
|
322
|
+
separator: '-',
|
|
323
|
+
length: 2,
|
|
324
|
+
});
|
|
325
|
+
const num = Math.floor(Math.random() * 900) + 100; // 100-999
|
|
326
|
+
return `${name}-${num}`;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { TaskStatus, isTerminalStatus } from './task-state.js';
|
|
6
|
+
import type { TaskState } from './task-state.js';
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Persisted profile state — cooldown / failure tracking for provider profiles
|
|
10
|
+
// Spec reference: §8.1
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
export interface PersistedProfileState {
|
|
14
|
+
id: string;
|
|
15
|
+
configDir: string;
|
|
16
|
+
cooldownUntil?: number;
|
|
17
|
+
failureCount: number;
|
|
18
|
+
lastFailureReason?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Top-level persisted state envelope
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export interface PersistedState {
|
|
26
|
+
version: 1;
|
|
27
|
+
tasks: TaskState[];
|
|
28
|
+
profiles: PersistedProfileState[];
|
|
29
|
+
lastSavedAt: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// persistenceDir — deterministic per-workspace directory
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export function persistenceDir(root: string, cwd: string): string {
|
|
37
|
+
const hash = createHash('md5').update(cwd).digest('hex');
|
|
38
|
+
return join(root, hash);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// saveState — atomic write: write tmp → rename
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
export async function saveState(dir: string, state: PersistedState): Promise<void> {
|
|
46
|
+
await mkdir(dir, { recursive: true });
|
|
47
|
+
const target = join(dir, 'state.json');
|
|
48
|
+
const tmp = join(dir, `state.json.tmp.${process.pid}`);
|
|
49
|
+
await writeFile(tmp, JSON.stringify(state, null, 2), 'utf-8');
|
|
50
|
+
await rename(tmp, target);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// loadState — read + parse, null on missing or corrupt
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
export async function loadState(dir: string): Promise<PersistedState | null> {
|
|
58
|
+
try {
|
|
59
|
+
const raw = await readFile(join(dir, 'state.json'), 'utf-8');
|
|
60
|
+
return JSON.parse(raw) as PersistedState;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// applyRecovery — per-task recovery on server restart
|
|
68
|
+
// Spec reference: §9.2 steps 1–2
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
export function applyRecovery(task: TaskState, hasCrashRecovery: boolean): TaskState {
|
|
72
|
+
// 1. Terminal states restore as-is
|
|
73
|
+
if (isTerminalStatus(task.status)) {
|
|
74
|
+
return task;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 2. Adapters without crash recovery → UNKNOWN
|
|
78
|
+
if (!hasCrashRecovery) {
|
|
79
|
+
const now = new Date().toISOString();
|
|
80
|
+
return {
|
|
81
|
+
...task,
|
|
82
|
+
status: TaskStatus.UNKNOWN,
|
|
83
|
+
error: `Server restarted; ${task.provider} sessions don't survive restarts`,
|
|
84
|
+
completedAt: now,
|
|
85
|
+
output: [
|
|
86
|
+
...task.output,
|
|
87
|
+
`[recovery] Task was ${task.status} when server restarted; marked UNKNOWN`,
|
|
88
|
+
],
|
|
89
|
+
recovered: true,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 3. Has crash recovery (e.g. Codex) — return as-is; adapter handles externally
|
|
94
|
+
return task;
|
|
95
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
export enum TaskStatus {
|
|
2
|
+
WAITING = 'waiting',
|
|
3
|
+
PENDING = 'pending',
|
|
4
|
+
RUNNING = 'running',
|
|
5
|
+
RATE_LIMITED = 'rate_limited',
|
|
6
|
+
WAITING_ANSWER = 'waiting_answer',
|
|
7
|
+
COMPLETED = 'completed',
|
|
8
|
+
FAILED = 'failed',
|
|
9
|
+
TIMED_OUT = 'timed_out',
|
|
10
|
+
CANCELLED = 'cancelled',
|
|
11
|
+
UNKNOWN = 'unknown',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const TERMINAL_STATUSES: ReadonlySet<TaskStatus> = new Set([
|
|
15
|
+
TaskStatus.COMPLETED,
|
|
16
|
+
TaskStatus.FAILED,
|
|
17
|
+
TaskStatus.CANCELLED,
|
|
18
|
+
TaskStatus.TIMED_OUT,
|
|
19
|
+
TaskStatus.UNKNOWN,
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
export function isTerminalStatus(status: TaskStatus): boolean {
|
|
23
|
+
return TERMINAL_STATUSES.has(status);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type TaskTypeName = 'coder' | 'planner' | 'tester' | 'researcher' | 'general';
|
|
27
|
+
|
|
28
|
+
export type Provider = 'codex' | 'copilot' | 'claude-cli';
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// PendingQuestion — discriminated union for 5 pause-flow question variants
|
|
32
|
+
// Spec reference: §3.3
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
export type PendingQuestion =
|
|
36
|
+
| {
|
|
37
|
+
type: 'user_input';
|
|
38
|
+
requestId: string;
|
|
39
|
+
questions: Array<{
|
|
40
|
+
id: string;
|
|
41
|
+
text: string;
|
|
42
|
+
options?: string[];
|
|
43
|
+
allowFreeform?: boolean;
|
|
44
|
+
}>;
|
|
45
|
+
}
|
|
46
|
+
| {
|
|
47
|
+
type: 'command_approval';
|
|
48
|
+
requestId: string;
|
|
49
|
+
command: string;
|
|
50
|
+
sandboxPolicy?: string;
|
|
51
|
+
}
|
|
52
|
+
| {
|
|
53
|
+
type: 'file_approval';
|
|
54
|
+
requestId: string;
|
|
55
|
+
fileChanges: Array<{ path: string; patch: string }>;
|
|
56
|
+
}
|
|
57
|
+
| {
|
|
58
|
+
type: 'elicitation';
|
|
59
|
+
requestId: string;
|
|
60
|
+
serverName?: string;
|
|
61
|
+
message: string;
|
|
62
|
+
schema?: unknown;
|
|
63
|
+
}
|
|
64
|
+
| {
|
|
65
|
+
type: 'dynamic_tool';
|
|
66
|
+
requestId: string;
|
|
67
|
+
toolName: string;
|
|
68
|
+
arguments: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type PendingQuestionType = PendingQuestion['type'];
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// TaskState — complete in-memory representation of a task
|
|
75
|
+
// Spec reference: §3.2 (data model), §4.1 (FSM states)
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
export interface TaskState {
|
|
79
|
+
// Identity
|
|
80
|
+
id: string;
|
|
81
|
+
status: TaskStatus;
|
|
82
|
+
provider: Provider;
|
|
83
|
+
taskType: TaskTypeName;
|
|
84
|
+
|
|
85
|
+
// Execution
|
|
86
|
+
prompt: string;
|
|
87
|
+
cwd: string;
|
|
88
|
+
model?: string;
|
|
89
|
+
sessionId?: string;
|
|
90
|
+
operationId?: string;
|
|
91
|
+
|
|
92
|
+
// Lifecycle timestamps
|
|
93
|
+
createdAt: string;
|
|
94
|
+
updatedAt: string;
|
|
95
|
+
startedAt?: string;
|
|
96
|
+
completedAt?: string;
|
|
97
|
+
lastOutputAt?: string;
|
|
98
|
+
timeoutMs?: number;
|
|
99
|
+
timeoutAt?: string;
|
|
100
|
+
keepAlive?: number;
|
|
101
|
+
|
|
102
|
+
// Output
|
|
103
|
+
output: string[];
|
|
104
|
+
outputFilePath?: string;
|
|
105
|
+
|
|
106
|
+
// Dependencies & labels
|
|
107
|
+
dependsOn?: string[];
|
|
108
|
+
labels: string[];
|
|
109
|
+
|
|
110
|
+
// Pause state
|
|
111
|
+
pendingQuestions: PendingQuestion[];
|
|
112
|
+
|
|
113
|
+
// Error tracking
|
|
114
|
+
error?: string;
|
|
115
|
+
exitCode?: number;
|
|
116
|
+
result?: unknown;
|
|
117
|
+
|
|
118
|
+
// Recovery markers
|
|
119
|
+
recovered?: boolean;
|
|
120
|
+
degraded?: boolean;
|
|
121
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { isTerminalStatus } from './task-state.js';
|
|
2
|
+
import type { TaskState, TaskStatus } from './task-state.js';
|
|
3
|
+
import { isValidTransition } from './fsm-transitions.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* In-memory task store with FSM-validated state transitions.
|
|
7
|
+
*
|
|
8
|
+
* This is the single source of truth for task state. It enforces the FSM at
|
|
9
|
+
* the data layer — illegal transitions are rejected before any mutation occurs.
|
|
10
|
+
*
|
|
11
|
+
* Terminal tasks can be evicted by age to prevent unbounded memory growth.
|
|
12
|
+
*/
|
|
13
|
+
export class TaskStore {
|
|
14
|
+
private readonly tasks = new Map<string, TaskState>();
|
|
15
|
+
|
|
16
|
+
/** Store a new task. Overwrites if the ID already exists. */
|
|
17
|
+
create(task: TaskState): void {
|
|
18
|
+
this.tasks.set(task.id, task);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Retrieve a task by its unique ID. */
|
|
22
|
+
get(id: string): TaskState | undefined {
|
|
23
|
+
return this.tasks.get(id);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Linear scan for a task matching the given session ID. */
|
|
27
|
+
getBySessionId(sessionId: string): TaskState | undefined {
|
|
28
|
+
for (const task of this.tasks.values()) {
|
|
29
|
+
if (task.sessionId === sessionId) return task;
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Return all tasks as an array. */
|
|
35
|
+
getAll(): TaskState[] {
|
|
36
|
+
return [...this.tasks.values()];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Transition a task to a new status.
|
|
41
|
+
*
|
|
42
|
+
* Returns `true` if the transition was valid and applied, `false` if the
|
|
43
|
+
* task was not found or the transition is illegal per the FSM.
|
|
44
|
+
*/
|
|
45
|
+
updateStatus(id: string, next: TaskStatus): boolean {
|
|
46
|
+
const task = this.tasks.get(id);
|
|
47
|
+
if (!task) return false;
|
|
48
|
+
|
|
49
|
+
if (!isValidTransition(task.status, next)) return false;
|
|
50
|
+
|
|
51
|
+
const now = new Date().toISOString();
|
|
52
|
+
task.status = next;
|
|
53
|
+
task.updatedAt = now;
|
|
54
|
+
|
|
55
|
+
if (isTerminalStatus(next)) {
|
|
56
|
+
task.completedAt = now;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Apply a partial update to a task. If the update includes a `status`
|
|
64
|
+
* change, the FSM is validated first — an illegal transition causes the
|
|
65
|
+
* entire update to be rejected (returns `undefined`).
|
|
66
|
+
*/
|
|
67
|
+
update(id: string, updates: Partial<TaskState>): TaskState | undefined {
|
|
68
|
+
const task = this.tasks.get(id);
|
|
69
|
+
if (!task) return undefined;
|
|
70
|
+
|
|
71
|
+
// Validate status transition if status is being changed
|
|
72
|
+
if (updates.status !== undefined && updates.status !== task.status) {
|
|
73
|
+
if (!isValidTransition(task.status, updates.status)) return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const now = new Date().toISOString();
|
|
77
|
+
|
|
78
|
+
Object.assign(task, updates, { updatedAt: now });
|
|
79
|
+
|
|
80
|
+
if (updates.status !== undefined && isTerminalStatus(updates.status)) {
|
|
81
|
+
task.completedAt = now;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return task;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Remove a task by ID. Returns `true` if it existed. */
|
|
88
|
+
delete(id: string): boolean {
|
|
89
|
+
return this.tasks.delete(id);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Remove terminal tasks whose `updatedAt` timestamp is older than
|
|
94
|
+
* `maxAgeMs` milliseconds from now.
|
|
95
|
+
*
|
|
96
|
+
* Returns the number of tasks evicted.
|
|
97
|
+
*/
|
|
98
|
+
evict(maxAgeMs: number): number {
|
|
99
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
100
|
+
let count = 0;
|
|
101
|
+
|
|
102
|
+
for (const [id, task] of this.tasks) {
|
|
103
|
+
if (isTerminalStatus(task.status) && new Date(task.updatedAt).getTime() < cutoff) {
|
|
104
|
+
this.tasks.delete(id);
|
|
105
|
+
count++;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return count;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Remove all tasks. */
|
|
113
|
+
clear(): void {
|
|
114
|
+
this.tasks.clear();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Return the number of stored tasks. */
|
|
118
|
+
size(): number {
|
|
119
|
+
return this.tasks.size;
|
|
120
|
+
}
|
|
121
|
+
}
|