tycono-server 0.1.0-beta.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/bin/cli.js +35 -0
- package/bin/server.ts +160 -0
- package/package.json +50 -0
- package/src/api/package.json +31 -0
- package/src/api/src/create-app.ts +90 -0
- package/src/api/src/create-server.ts +251 -0
- package/src/api/src/engine/agent-loop.ts +738 -0
- package/src/api/src/engine/authority-validator.ts +149 -0
- package/src/api/src/engine/context-assembler.ts +912 -0
- package/src/api/src/engine/index.ts +27 -0
- package/src/api/src/engine/knowledge-gate.ts +365 -0
- package/src/api/src/engine/llm-adapter.ts +304 -0
- package/src/api/src/engine/org-tree.ts +270 -0
- package/src/api/src/engine/role-lifecycle.ts +369 -0
- package/src/api/src/engine/runners/claude-cli.ts +796 -0
- package/src/api/src/engine/runners/direct-api.ts +66 -0
- package/src/api/src/engine/runners/index.ts +30 -0
- package/src/api/src/engine/runners/types.ts +95 -0
- package/src/api/src/engine/skill-template.ts +134 -0
- package/src/api/src/engine/tools/definitions.ts +201 -0
- package/src/api/src/engine/tools/executor.ts +611 -0
- package/src/api/src/routes/active-sessions.ts +134 -0
- package/src/api/src/routes/coins.ts +153 -0
- package/src/api/src/routes/company.ts +57 -0
- package/src/api/src/routes/cost.ts +141 -0
- package/src/api/src/routes/engine.ts +220 -0
- package/src/api/src/routes/execute.ts +1075 -0
- package/src/api/src/routes/git.ts +211 -0
- package/src/api/src/routes/knowledge.ts +378 -0
- package/src/api/src/routes/operations.ts +309 -0
- package/src/api/src/routes/preferences.ts +63 -0
- package/src/api/src/routes/presets.ts +123 -0
- package/src/api/src/routes/projects.ts +82 -0
- package/src/api/src/routes/quests.ts +41 -0
- package/src/api/src/routes/roles.ts +112 -0
- package/src/api/src/routes/save.ts +152 -0
- package/src/api/src/routes/sessions.ts +288 -0
- package/src/api/src/routes/setup.ts +437 -0
- package/src/api/src/routes/skills.ts +357 -0
- package/src/api/src/routes/speech.ts +959 -0
- package/src/api/src/routes/supervision.ts +136 -0
- package/src/api/src/routes/sync.ts +165 -0
- package/src/api/src/server.ts +59 -0
- package/src/api/src/services/activity-stream.ts +184 -0
- package/src/api/src/services/activity-tracker.ts +115 -0
- package/src/api/src/services/claude-md-manager.ts +94 -0
- package/src/api/src/services/company-config.ts +115 -0
- package/src/api/src/services/database.ts +77 -0
- package/src/api/src/services/digest-engine.ts +313 -0
- package/src/api/src/services/execution-manager.ts +1036 -0
- package/src/api/src/services/file-reader.ts +77 -0
- package/src/api/src/services/git-save.ts +614 -0
- package/src/api/src/services/job-manager.ts +16 -0
- package/src/api/src/services/knowledge-importer.ts +466 -0
- package/src/api/src/services/markdown-parser.ts +173 -0
- package/src/api/src/services/port-registry.ts +222 -0
- package/src/api/src/services/preferences.ts +150 -0
- package/src/api/src/services/preset-loader.ts +149 -0
- package/src/api/src/services/pricing.ts +34 -0
- package/src/api/src/services/scaffold.ts +546 -0
- package/src/api/src/services/session-store.ts +340 -0
- package/src/api/src/services/supervisor-heartbeat.ts +897 -0
- package/src/api/src/services/team-recommender.ts +382 -0
- package/src/api/src/services/token-ledger.ts +127 -0
- package/src/api/src/services/wave-messages.ts +194 -0
- package/src/api/src/services/wave-multiplexer.ts +356 -0
- package/src/api/src/services/wave-tracker.ts +359 -0
- package/src/api/src/utils/role-level.ts +31 -0
- package/src/core/scaffolder.ts +620 -0
- package/src/shared/types.ts +224 -0
- package/templates/CLAUDE.md.tmpl +239 -0
- package/templates/company.md.tmpl +17 -0
- package/templates/gitignore.tmpl +28 -0
- package/templates/roles.md.tmpl +8 -0
- package/templates/skills/_manifest.json +23 -0
- package/templates/skills/agent-browser/SKILL.md +159 -0
- package/templates/skills/agent-browser/meta.json +19 -0
- package/templates/skills/akb-linter/SKILL.md +125 -0
- package/templates/skills/akb-linter/meta.json +12 -0
- package/templates/skills/knowledge-gate/SKILL.md +120 -0
- package/templates/skills/knowledge-gate/meta.json +12 -0
- package/templates/teams/agency.json +58 -0
- package/templates/teams/research.json +58 -0
- package/templates/teams/startup.json +58 -0
|
@@ -0,0 +1,1036 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { COMPANY_ROOT } from './file-reader.js';
|
|
4
|
+
import { ActivityStream, type ActivityEvent } from './activity-stream.js';
|
|
5
|
+
import { buildOrgTree } from '../engine/org-tree.js';
|
|
6
|
+
import { validateDispatch, validateConsult } from '../engine/authority-validator.js';
|
|
7
|
+
import { createRunner } from '../engine/runners/index.js';
|
|
8
|
+
import type { ExecutionRunner } from '../engine/runners/types.js';
|
|
9
|
+
// activity-tracker removed — executionManager is Single Source of Truth for role status
|
|
10
|
+
import type { RunnerResult } from '../engine/runners/types.js';
|
|
11
|
+
import { estimateCost } from './pricing.js';
|
|
12
|
+
import { readConfig, getConversationLimits, resolveCodeRoot } from './company-config.js';
|
|
13
|
+
import { postKnowledgingCheck, type KnowledgeDebtItem } from '../engine/knowledge-gate.js';
|
|
14
|
+
import { earnCoinsInternal } from '../routes/coins.js';
|
|
15
|
+
import { getSession, createSession, addMessage, updateMessage as updateSessionMessage, updateSession, appendMessageEvent, type Message, type ImageAttachment } from './session-store.js';
|
|
16
|
+
import { portRegistry, type PortAllocation } from './port-registry.js';
|
|
17
|
+
import { type MessageStatus, isMessageActive, canTransition, messageStatusToRoleStatus } from '../../../shared/types.js';
|
|
18
|
+
|
|
19
|
+
/* ─── Types ─── */
|
|
20
|
+
|
|
21
|
+
export type ExecStatus = 'idle' | 'running' | 'done' | 'error' | 'awaiting_input' | 'interrupted';
|
|
22
|
+
export type ExecType = 'assign' | 'wave' | 'consult';
|
|
23
|
+
|
|
24
|
+
export { canTransition, messageStatusToRoleStatus } from '../../../shared/types.js';
|
|
25
|
+
|
|
26
|
+
export interface Execution {
|
|
27
|
+
id: string;
|
|
28
|
+
sessionId: string;
|
|
29
|
+
type: ExecType;
|
|
30
|
+
roleId: string;
|
|
31
|
+
task: string;
|
|
32
|
+
status: ExecStatus;
|
|
33
|
+
stream: ActivityStream;
|
|
34
|
+
abort: () => void;
|
|
35
|
+
parentSessionId?: string;
|
|
36
|
+
childSessionIds: string[];
|
|
37
|
+
createdAt: string;
|
|
38
|
+
result?: RunnerResult;
|
|
39
|
+
error?: string;
|
|
40
|
+
targetRole?: string;
|
|
41
|
+
targetRoles?: string[];
|
|
42
|
+
knowledgeDebt?: KnowledgeDebtItem[];
|
|
43
|
+
ports?: PortAllocation;
|
|
44
|
+
traceId?: string;
|
|
45
|
+
/** CLI session ID for --resume (captured from Claude CLI result event) */
|
|
46
|
+
cliSessionId?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface StartExecutionParams {
|
|
50
|
+
type: ExecType;
|
|
51
|
+
roleId: string;
|
|
52
|
+
task: string;
|
|
53
|
+
sourceRole?: string;
|
|
54
|
+
readOnly?: boolean;
|
|
55
|
+
parentSessionId?: string;
|
|
56
|
+
model?: string;
|
|
57
|
+
isContinuation?: boolean;
|
|
58
|
+
targetRoles?: string[];
|
|
59
|
+
sessionId: string;
|
|
60
|
+
attachments?: ImageAttachment[];
|
|
61
|
+
/** CLI session ID for --resume (context continuity across turn limits) */
|
|
62
|
+
cliSessionId?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* ─── Helpers ────────────────────────────── */
|
|
66
|
+
|
|
67
|
+
function summarizeInput(input: Record<string, unknown>): Record<string, unknown> {
|
|
68
|
+
const summary: Record<string, unknown> = {};
|
|
69
|
+
for (const [key, value] of Object.entries(input)) {
|
|
70
|
+
if (typeof value === 'string' && value.length > 200) {
|
|
71
|
+
summary[key] = value.slice(0, 200) + '...';
|
|
72
|
+
} else {
|
|
73
|
+
summary[key] = value;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return summary;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function hasQuestion(output: string): boolean {
|
|
80
|
+
const lastBlock = output.trim().split('\n').slice(-5).join('\n');
|
|
81
|
+
return /\?\s*$/.test(lastBlock) || /할까요|해볼까요|어떨까요|확인.*필요/.test(lastBlock);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isExecActive(status: ExecStatus): boolean {
|
|
85
|
+
return status === 'running' || status === 'awaiting_input';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolveTargetRole(sourceRole: string | undefined, parentSessionId: string | undefined, executions: Map<string, Execution>): string {
|
|
89
|
+
if (sourceRole && sourceRole !== 'ceo') return sourceRole;
|
|
90
|
+
|
|
91
|
+
if (parentSessionId) {
|
|
92
|
+
for (const exec of executions.values()) {
|
|
93
|
+
if (exec.sessionId === parentSessionId && exec.roleId !== 'ceo') {
|
|
94
|
+
return exec.roleId;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return 'ceo';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* ─── ExecutionManager Singleton ───────────────── */
|
|
103
|
+
|
|
104
|
+
class ExecutionManager {
|
|
105
|
+
private executions = new Map<string, Execution>();
|
|
106
|
+
private runner = createRunner();
|
|
107
|
+
private nextId = 1;
|
|
108
|
+
private executionCreatedListeners = new Set<(exec: Execution) => void>();
|
|
109
|
+
|
|
110
|
+
setRunner(newRunner: ExecutionRunner): void {
|
|
111
|
+
this.runner = newRunner;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
refreshRunner(): void {
|
|
115
|
+
this.runner = createRunner();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
onExecutionCreated(listener: (exec: Execution) => void): () => void {
|
|
119
|
+
this.executionCreatedListeners.add(listener);
|
|
120
|
+
return () => { this.executionCreatedListeners.delete(listener); };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
startExecution(params: StartExecutionParams): Execution {
|
|
124
|
+
const execId = `exec-${Date.now()}-${this.nextId++}`;
|
|
125
|
+
|
|
126
|
+
// Resolve preset from wave file for org tree building
|
|
127
|
+
let presetId: string | undefined;
|
|
128
|
+
const session = getSession(params.sessionId);
|
|
129
|
+
if (session?.waveId) {
|
|
130
|
+
try {
|
|
131
|
+
const wavePath = path.join(COMPANY_ROOT, '.tycono', 'waves', `${session.waveId}.json`);
|
|
132
|
+
if (fs.existsSync(wavePath)) {
|
|
133
|
+
const waveData = JSON.parse(fs.readFileSync(wavePath, 'utf-8'));
|
|
134
|
+
presetId = waveData.preset;
|
|
135
|
+
}
|
|
136
|
+
} catch { /* ignore */ }
|
|
137
|
+
}
|
|
138
|
+
const orgTree = buildOrgTree(COMPANY_ROOT, presetId);
|
|
139
|
+
|
|
140
|
+
// Authority gate
|
|
141
|
+
if (params.sourceRole && params.sourceRole !== 'ceo') {
|
|
142
|
+
if (params.type === 'consult') {
|
|
143
|
+
const auth = validateConsult(orgTree, params.sourceRole, params.roleId);
|
|
144
|
+
if (!auth.allowed) {
|
|
145
|
+
throw new Error(`Authority denied: ${auth.reason}`);
|
|
146
|
+
}
|
|
147
|
+
} else if (params.type === 'assign' && params.parentSessionId) {
|
|
148
|
+
const auth = validateDispatch(orgTree, params.sourceRole, params.roleId);
|
|
149
|
+
if (!auth.allowed) {
|
|
150
|
+
throw new Error(`Authority denied: ${auth.reason}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Resolve traceId: root sessions use their own sessionId, children inherit from parent
|
|
156
|
+
let traceId = params.sessionId;
|
|
157
|
+
if (params.parentSessionId) {
|
|
158
|
+
// Find the root trace by walking up the parent chain
|
|
159
|
+
for (const exec of this.executions.values()) {
|
|
160
|
+
if (exec.sessionId === params.parentSessionId) {
|
|
161
|
+
traceId = exec.traceId ?? params.parentSessionId;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const stream = ActivityStream.getOrCreate(params.sessionId, params.roleId, params.parentSessionId, traceId);
|
|
168
|
+
|
|
169
|
+
const execution: Execution = {
|
|
170
|
+
id: execId,
|
|
171
|
+
sessionId: params.sessionId,
|
|
172
|
+
type: params.type,
|
|
173
|
+
roleId: params.roleId,
|
|
174
|
+
task: params.task,
|
|
175
|
+
status: 'running',
|
|
176
|
+
stream,
|
|
177
|
+
abort: () => {},
|
|
178
|
+
parentSessionId: params.parentSessionId,
|
|
179
|
+
childSessionIds: [],
|
|
180
|
+
createdAt: new Date().toISOString(),
|
|
181
|
+
targetRoles: params.targetRoles,
|
|
182
|
+
traceId,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
this.executions.set(execId, execution);
|
|
186
|
+
|
|
187
|
+
this.initializeAndRunExecution(execution, params, orgTree, presetId);
|
|
188
|
+
|
|
189
|
+
return execution;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private async initializeAndRunExecution(
|
|
193
|
+
execution: Execution,
|
|
194
|
+
params: StartExecutionParams,
|
|
195
|
+
orgTree: ReturnType<typeof buildOrgTree>,
|
|
196
|
+
presetId?: string,
|
|
197
|
+
): Promise<void> {
|
|
198
|
+
try {
|
|
199
|
+
const ports = await portRegistry.allocate(execution.sessionId || execution.id, params.roleId, params.task);
|
|
200
|
+
execution.ports = ports;
|
|
201
|
+
console.log(`[ExecMgr] Allocated ports for ${execution.id} (${params.roleId}): API :${ports.api}, Vite :${ports.vite}`);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
console.warn(`[ExecMgr] Port allocation failed for ${execution.id}:`, err);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Emit msg:start
|
|
207
|
+
execution.stream.emit('msg:start', params.roleId, {
|
|
208
|
+
traceId: execution.traceId,
|
|
209
|
+
type: params.type,
|
|
210
|
+
task: params.task,
|
|
211
|
+
sourceRole: params.sourceRole ?? 'ceo',
|
|
212
|
+
sessionId: params.sessionId,
|
|
213
|
+
...(params.parentSessionId && { parentSessionId: params.parentSessionId }),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// If this execution has a parent session, emit dispatch:start on the parent's stream
|
|
217
|
+
if (params.parentSessionId) {
|
|
218
|
+
const parentExec = this.getActiveExecution(params.parentSessionId);
|
|
219
|
+
if (parentExec) {
|
|
220
|
+
parentExec.childSessionIds.push(params.sessionId);
|
|
221
|
+
parentExec.stream.emit('dispatch:start', parentExec.roleId, {
|
|
222
|
+
targetRoleId: params.roleId,
|
|
223
|
+
task: params.task,
|
|
224
|
+
childSessionId: params.sessionId,
|
|
225
|
+
parentSessionId: parentExec.sessionId,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const model = params.model ?? orgTree.nodes.get(params.roleId)?.model;
|
|
231
|
+
|
|
232
|
+
const config = readConfig(COMPANY_ROOT);
|
|
233
|
+
const limits = getConversationLimits(config);
|
|
234
|
+
let harnessTurnCount = 0;
|
|
235
|
+
let softLimitWarned = false;
|
|
236
|
+
let hardLimitReached = false;
|
|
237
|
+
const outputChunks: string[] = [];
|
|
238
|
+
|
|
239
|
+
const teamStatus: import('../../../shared/types').TeamStatus = {};
|
|
240
|
+
for (const [, e] of this.executions) {
|
|
241
|
+
if (e.status === 'running' && e.id !== execution.id) {
|
|
242
|
+
teamStatus[e.roleId] = { status: 'working', task: e.task };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const portEnv = execution.ports ? {
|
|
247
|
+
API_PORT: String(execution.ports.api),
|
|
248
|
+
PORT: String(execution.ports.api),
|
|
249
|
+
VITE_PORT: String(execution.ports.vite),
|
|
250
|
+
...(execution.ports.hmr && { VITE_HMR_PORT: String(execution.ports.hmr) }),
|
|
251
|
+
} : {};
|
|
252
|
+
|
|
253
|
+
const handle = this.runner.execute(
|
|
254
|
+
{
|
|
255
|
+
companyRoot: COMPANY_ROOT,
|
|
256
|
+
roleId: params.roleId,
|
|
257
|
+
task: params.task,
|
|
258
|
+
sourceRole: params.sourceRole ?? 'ceo',
|
|
259
|
+
orgTree,
|
|
260
|
+
readOnly: params.readOnly,
|
|
261
|
+
maxTurns: limits.hardLimit,
|
|
262
|
+
model,
|
|
263
|
+
sessionId: params.sessionId,
|
|
264
|
+
teamStatus,
|
|
265
|
+
targetRoles: params.targetRoles,
|
|
266
|
+
presetId,
|
|
267
|
+
codeRoot: resolveCodeRoot(COMPANY_ROOT),
|
|
268
|
+
attachments: params.attachments,
|
|
269
|
+
cliSessionId: params.cliSessionId,
|
|
270
|
+
env: {
|
|
271
|
+
...process.env,
|
|
272
|
+
...portEnv,
|
|
273
|
+
},
|
|
274
|
+
// SV-6, SV-7: Supervision callbacks (direct-api runner only)
|
|
275
|
+
onAbortSession: (sessionId: string) => this.abortSession(sessionId),
|
|
276
|
+
onAmendSession: (sessionId: string, instruction: string) => {
|
|
277
|
+
const result = this.continueSession(sessionId, `[SUPERVISION AMENDMENT] ${instruction}`, params.roleId);
|
|
278
|
+
return result !== null;
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
onText: (text) => {
|
|
283
|
+
outputChunks.push(text);
|
|
284
|
+
execution.stream.emit('text', params.roleId, { text });
|
|
285
|
+
if (execution.sessionId) {
|
|
286
|
+
this.updateSessionRoleMessage(execution, text);
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
onThinking: (text) => {
|
|
290
|
+
execution.stream.emit('thinking', params.roleId, { text });
|
|
291
|
+
if (execution.sessionId) {
|
|
292
|
+
this.embedSessionEvent(execution, 'thinking', { text: text.slice(0, 200) });
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
onToolUse: (name, input) => {
|
|
296
|
+
execution.stream.emit('tool:start', params.roleId, {
|
|
297
|
+
name,
|
|
298
|
+
input: input ? summarizeInput(input) : undefined,
|
|
299
|
+
});
|
|
300
|
+
if (execution.sessionId) {
|
|
301
|
+
this.embedSessionEvent(execution, 'tool:start', {
|
|
302
|
+
name,
|
|
303
|
+
input: input ? summarizeInput(input) : undefined,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
onDispatch: (subRoleId, subTask) => {
|
|
308
|
+
if (params.targetRoles && params.targetRoles.length > 0) {
|
|
309
|
+
if (!params.targetRoles.includes(subRoleId)) {
|
|
310
|
+
console.warn(`[ExecMgr] Dispatch blocked: ${params.roleId} → ${subRoleId} (not in targetRoles)`);
|
|
311
|
+
execution.stream.emit('stderr', params.roleId, {
|
|
312
|
+
message: `Dispatch to ${subRoleId} blocked — not in active target scope for this wave.`,
|
|
313
|
+
});
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// BUG-W02 fix: propagate waveId from parent session to child
|
|
319
|
+
const parentSession = getSession(execution.sessionId);
|
|
320
|
+
const parentWaveId = parentSession?.waveId;
|
|
321
|
+
|
|
322
|
+
const childSession = createSession(subRoleId, {
|
|
323
|
+
mode: 'do',
|
|
324
|
+
source: 'dispatch',
|
|
325
|
+
parentSessionId: execution.sessionId,
|
|
326
|
+
...(parentWaveId && { waveId: parentWaveId }),
|
|
327
|
+
});
|
|
328
|
+
const dispatchMsg: Message = {
|
|
329
|
+
id: `msg-${Date.now()}-dispatch-${subRoleId}`,
|
|
330
|
+
from: 'ceo',
|
|
331
|
+
content: subTask,
|
|
332
|
+
type: 'directive',
|
|
333
|
+
status: 'done',
|
|
334
|
+
timestamp: new Date().toISOString(),
|
|
335
|
+
};
|
|
336
|
+
addMessage(childSession.id, dispatchMsg);
|
|
337
|
+
|
|
338
|
+
const childExec = this.startExecution({
|
|
339
|
+
type: 'assign',
|
|
340
|
+
roleId: subRoleId,
|
|
341
|
+
task: subTask,
|
|
342
|
+
sourceRole: params.roleId,
|
|
343
|
+
parentSessionId: execution.sessionId,
|
|
344
|
+
targetRoles: params.targetRoles,
|
|
345
|
+
sessionId: childSession.id,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const childRoleMsg: Message = {
|
|
349
|
+
id: `msg-${Date.now() + 1}-role-${subRoleId}`,
|
|
350
|
+
from: 'role',
|
|
351
|
+
content: '',
|
|
352
|
+
type: 'conversation',
|
|
353
|
+
status: 'streaming',
|
|
354
|
+
timestamp: new Date().toISOString(),
|
|
355
|
+
};
|
|
356
|
+
addMessage(childSession.id, childRoleMsg, true);
|
|
357
|
+
|
|
358
|
+
if (execution.sessionId) {
|
|
359
|
+
this.embedSessionEvent(execution, 'dispatch:start', {
|
|
360
|
+
roleId: subRoleId,
|
|
361
|
+
task: subTask,
|
|
362
|
+
childSessionId: childSession.id,
|
|
363
|
+
targetRoleId: subRoleId,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
onConsult: (subRoleId, question) => {
|
|
368
|
+
this.startExecution({
|
|
369
|
+
type: 'consult',
|
|
370
|
+
roleId: subRoleId,
|
|
371
|
+
task: `[Consultation from ${params.roleId}] ${question}\n\nAnswer this question based on your role's expertise and knowledge. Be concise and specific.`,
|
|
372
|
+
sourceRole: params.roleId,
|
|
373
|
+
readOnly: true,
|
|
374
|
+
parentSessionId: execution.sessionId,
|
|
375
|
+
sessionId: `ses-consult-${Date.now()}-${subRoleId}`,
|
|
376
|
+
});
|
|
377
|
+
},
|
|
378
|
+
onTurnComplete: (turn) => {
|
|
379
|
+
harnessTurnCount++;
|
|
380
|
+
execution.stream.emit('msg:turn-complete', params.roleId, {
|
|
381
|
+
turn: harnessTurnCount,
|
|
382
|
+
runnerTurn: turn,
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
if (!softLimitWarned && harnessTurnCount >= limits.softLimit) {
|
|
386
|
+
softLimitWarned = true;
|
|
387
|
+
console.warn(
|
|
388
|
+
`[Harness] Exec ${execution.id} (${params.roleId}): turn ${harnessTurnCount} reached softLimit (${limits.softLimit})`,
|
|
389
|
+
);
|
|
390
|
+
execution.stream.emit('turn:warning', params.roleId, {
|
|
391
|
+
turn: harnessTurnCount,
|
|
392
|
+
softLimit: limits.softLimit,
|
|
393
|
+
hardLimit: limits.hardLimit,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (harnessTurnCount >= limits.hardLimit) {
|
|
398
|
+
hardLimitReached = true;
|
|
399
|
+
console.warn(
|
|
400
|
+
`[Harness] Exec ${execution.id} (${params.roleId}): turn ${harnessTurnCount} reached hardLimit (${limits.hardLimit}). Pausing for approval.`,
|
|
401
|
+
);
|
|
402
|
+
execution.stream.emit('turn:limit', params.roleId, {
|
|
403
|
+
turn: harnessTurnCount,
|
|
404
|
+
hardLimit: limits.hardLimit,
|
|
405
|
+
});
|
|
406
|
+
handle.abort();
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
onPromptAssembled: (systemPrompt, userTask) => {
|
|
410
|
+
execution.stream.emit('prompt:assembled', params.roleId, {
|
|
411
|
+
systemPrompt,
|
|
412
|
+
userTask,
|
|
413
|
+
systemPromptLength: systemPrompt.length,
|
|
414
|
+
});
|
|
415
|
+
},
|
|
416
|
+
onError: (error) => {
|
|
417
|
+
execution.stream.emit('msg:error', params.roleId, { message: error });
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
execution.abort = handle.abort;
|
|
423
|
+
|
|
424
|
+
// Notify listeners
|
|
425
|
+
for (const listener of this.executionCreatedListeners) {
|
|
426
|
+
try { listener(execution); } catch { /* ignore */ }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
handle.promise
|
|
430
|
+
.then((result: RunnerResult) => {
|
|
431
|
+
execution.result = result;
|
|
432
|
+
if (result.cliSessionId) execution.cliSessionId = result.cliSessionId;
|
|
433
|
+
|
|
434
|
+
const costUsd = estimateCost(
|
|
435
|
+
result.totalTokens.input,
|
|
436
|
+
result.totalTokens.output,
|
|
437
|
+
model ?? '',
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
execution.stream.emit('trace:response', params.roleId, {
|
|
441
|
+
fullOutput: result.output,
|
|
442
|
+
outputLength: result.output.length,
|
|
443
|
+
turns: result.turns,
|
|
444
|
+
tokens: result.totalTokens,
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
const doneData = {
|
|
448
|
+
output: result.output.slice(-1000),
|
|
449
|
+
turns: result.turns,
|
|
450
|
+
tokens: result.totalTokens,
|
|
451
|
+
costUsd,
|
|
452
|
+
toolCalls: result.toolCalls.length,
|
|
453
|
+
dispatches: result.dispatches.map((d) => ({ roleId: d.roleId, task: d.task })),
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const targetRole = resolveTargetRole(params.sourceRole, params.parentSessionId, this.executions);
|
|
457
|
+
|
|
458
|
+
if (hardLimitReached) {
|
|
459
|
+
execution.status = 'awaiting_input';
|
|
460
|
+
execution.targetRole = targetRole;
|
|
461
|
+
const question = `[Turn limit] ${harnessTurnCount}턴 도달 (hardLimit: ${limits.hardLimit}). 계속 진행할까요?`;
|
|
462
|
+
execution.stream.emit('msg:awaiting_input', params.roleId, {
|
|
463
|
+
...doneData,
|
|
464
|
+
question,
|
|
465
|
+
awaitingInput: true,
|
|
466
|
+
targetRole,
|
|
467
|
+
reason: 'turn_limit',
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
// Auto-continue on turn limit: resume with --resume for context continuity
|
|
471
|
+
// Delay slightly to allow stream event to propagate
|
|
472
|
+
setTimeout(() => {
|
|
473
|
+
console.log(`[Harness] Auto-continuing ${params.roleId} (${execution.sessionId}) after turn limit`);
|
|
474
|
+
this.continueSession(execution.sessionId, '턴 한도에 도달했습니다. 이전 작업을 이어서 계속 진행하세요.');
|
|
475
|
+
}, 3_000);
|
|
476
|
+
} else if (!params.isContinuation && hasQuestion(result.output)) {
|
|
477
|
+
execution.status = 'awaiting_input';
|
|
478
|
+
execution.targetRole = targetRole;
|
|
479
|
+
execution.stream.emit('msg:awaiting_input', params.roleId, {
|
|
480
|
+
...doneData,
|
|
481
|
+
question: result.output.trim().split('\n').slice(-5).join('\n'),
|
|
482
|
+
awaitingInput: true,
|
|
483
|
+
targetRole,
|
|
484
|
+
});
|
|
485
|
+
} else {
|
|
486
|
+
const changedMdFiles = result.toolCalls
|
|
487
|
+
.filter(tc => (tc.name === 'write_file' || tc.name === 'edit_file') && tc.input && typeof tc.input.path === 'string')
|
|
488
|
+
.map(tc => String(tc.input!.path))
|
|
489
|
+
.filter(p => p.endsWith('.md'));
|
|
490
|
+
|
|
491
|
+
if (changedMdFiles.length > 0) {
|
|
492
|
+
try {
|
|
493
|
+
const pkResult = postKnowledgingCheck(COMPANY_ROOT, changedMdFiles);
|
|
494
|
+
if (!pkResult.pass) {
|
|
495
|
+
execution.knowledgeDebt = pkResult.debt;
|
|
496
|
+
console.log(
|
|
497
|
+
`[Post-K] Exec ${execution.id} (${params.roleId}): ${pkResult.debt.length} knowledge debt item(s)`,
|
|
498
|
+
);
|
|
499
|
+
for (const d of pkResult.debt) {
|
|
500
|
+
console.log(` [Post-K] ${d.type}: ${d.message}`);
|
|
501
|
+
}
|
|
502
|
+
(doneData as Record<string, unknown>).knowledgeDebt = pkResult.debt.map(d => ({
|
|
503
|
+
type: d.type,
|
|
504
|
+
file: d.file,
|
|
505
|
+
message: d.message,
|
|
506
|
+
}));
|
|
507
|
+
}
|
|
508
|
+
} catch (err) {
|
|
509
|
+
console.warn('[Post-K] Check failed:', err);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
execution.status = 'done';
|
|
514
|
+
execution.stream.emit('msg:done', params.roleId, doneData);
|
|
515
|
+
if (execution.sessionId) {
|
|
516
|
+
this.finalizeSessionMessage(execution, 'done', result);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (!params.parentSessionId && result) {
|
|
520
|
+
const totalTokens = (result.totalTokens?.input ?? 0) + (result.totalTokens?.output ?? 0);
|
|
521
|
+
const bonus = Math.min(2000, Math.max(500, Math.round(totalTokens / 500)));
|
|
522
|
+
try {
|
|
523
|
+
earnCoinsInternal(bonus, `Execution done: ${params.roleId}`, `exec:${execution.id}`);
|
|
524
|
+
} catch { /* non-critical */ }
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
this.cleanupOrphanedChildren(execution.sessionId);
|
|
528
|
+
this.attemptSupervisionRecovery(execution);
|
|
529
|
+
}
|
|
530
|
+
})
|
|
531
|
+
.catch((err: Error) => {
|
|
532
|
+
if (hardLimitReached) {
|
|
533
|
+
execution.result = {
|
|
534
|
+
output: outputChunks.join(''),
|
|
535
|
+
turns: harnessTurnCount,
|
|
536
|
+
totalTokens: { input: 0, output: 0 },
|
|
537
|
+
toolCalls: [],
|
|
538
|
+
dispatches: [],
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
const targetRole = resolveTargetRole(params.sourceRole, params.parentSessionId, this.executions);
|
|
542
|
+
execution.status = 'awaiting_input';
|
|
543
|
+
execution.targetRole = targetRole;
|
|
544
|
+
const question = `[Turn limit] ${harnessTurnCount}턴 도달 (hardLimit: ${limits.hardLimit}). 계속 진행할까요?`;
|
|
545
|
+
execution.stream.emit('msg:awaiting_input', params.roleId, {
|
|
546
|
+
question,
|
|
547
|
+
awaitingInput: true,
|
|
548
|
+
targetRole,
|
|
549
|
+
reason: 'turn_limit',
|
|
550
|
+
});
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
execution.status = 'error';
|
|
555
|
+
execution.error = err.message;
|
|
556
|
+
|
|
557
|
+
execution.stream.emit('msg:error', params.roleId, { message: err.message });
|
|
558
|
+
if (execution.sessionId) {
|
|
559
|
+
this.finalizeSessionMessage(execution, 'error');
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// SV: If C-Level crashed with running children, restart supervision
|
|
563
|
+
this.attemptSupervisionRecovery(execution);
|
|
564
|
+
})
|
|
565
|
+
.finally(() => {
|
|
566
|
+
if (execution.ports) {
|
|
567
|
+
const released = portRegistry.release(execution.sessionId || execution.id);
|
|
568
|
+
if (released) {
|
|
569
|
+
console.log(`[ExecMgr] Released ports for ${execution.id}: API :${execution.ports.api}, Vite :${execution.ports.vite}`);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Clean up sessionMsgContent immediately (no longer needed after finalize)
|
|
574
|
+
if (execution.sessionId) {
|
|
575
|
+
for (const key of this.sessionMsgContent.keys()) {
|
|
576
|
+
if (key.startsWith(execution.sessionId + ':')) {
|
|
577
|
+
this.sessionMsgContent.delete(key);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// OOM prevention: remove completed execution from memory after delay
|
|
583
|
+
// (delay allows getActiveExecution to find it briefly for multiplexer/recovery)
|
|
584
|
+
setTimeout(() => {
|
|
585
|
+
this.executions.delete(execution.id);
|
|
586
|
+
// Also close the ActivityStream to free subscribers + file handles
|
|
587
|
+
execution.stream.close();
|
|
588
|
+
}, 30_000).unref();
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Debug: return memory stats for monitoring */
|
|
593
|
+
getMemoryStats(): { executions: number; msgContentKeys: number; msgContentSize: number } {
|
|
594
|
+
let msgContentSize = 0;
|
|
595
|
+
for (const v of this.sessionMsgContent.values()) {
|
|
596
|
+
msgContentSize += v.length;
|
|
597
|
+
}
|
|
598
|
+
return {
|
|
599
|
+
executions: this.executions.size,
|
|
600
|
+
msgContentKeys: this.sessionMsgContent.size,
|
|
601
|
+
msgContentSize,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/* ─── Session ↔ Execution bridge ───────── */
|
|
606
|
+
|
|
607
|
+
private sessionMsgContent = new Map<string, string>();
|
|
608
|
+
|
|
609
|
+
private updateSessionRoleMessage(execution: Execution, text: string): void {
|
|
610
|
+
if (!execution.sessionId) return;
|
|
611
|
+
const session = getSession(execution.sessionId);
|
|
612
|
+
if (!session) return;
|
|
613
|
+
|
|
614
|
+
// Find the latest streaming role message
|
|
615
|
+
const roleMsg = [...session.messages].reverse().find(m => m.from === 'role' && m.status === 'streaming');
|
|
616
|
+
if (!roleMsg) return;
|
|
617
|
+
|
|
618
|
+
const key = `${execution.sessionId}:${roleMsg.id}`;
|
|
619
|
+
const current = (this.sessionMsgContent.get(key) ?? '') + text;
|
|
620
|
+
this.sessionMsgContent.set(key, current);
|
|
621
|
+
|
|
622
|
+
updateSessionMessage(execution.sessionId, roleMsg.id, { content: current });
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
private embedSessionEvent(execution: Execution, type: string, data: Record<string, unknown>): void {
|
|
626
|
+
if (!execution.sessionId) return;
|
|
627
|
+
const session = getSession(execution.sessionId);
|
|
628
|
+
if (!session) return;
|
|
629
|
+
|
|
630
|
+
const roleMsg = [...session.messages].reverse().find(m => m.from === 'role' && m.status === 'streaming');
|
|
631
|
+
if (!roleMsg) return;
|
|
632
|
+
|
|
633
|
+
const event: ActivityEvent = {
|
|
634
|
+
seq: (roleMsg.events?.length ?? 0) + 1,
|
|
635
|
+
ts: new Date().toISOString(),
|
|
636
|
+
type: type as ActivityEvent['type'],
|
|
637
|
+
roleId: execution.roleId,
|
|
638
|
+
data,
|
|
639
|
+
};
|
|
640
|
+
appendMessageEvent(execution.sessionId, roleMsg.id, event);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
private finalizeSessionMessage(execution: Execution, status: 'done' | 'error', result?: RunnerResult): void {
|
|
644
|
+
if (!execution.sessionId) return;
|
|
645
|
+
const session = getSession(execution.sessionId);
|
|
646
|
+
if (!session) return;
|
|
647
|
+
|
|
648
|
+
const roleMsg = [...session.messages].reverse().find(m => m.from === 'role');
|
|
649
|
+
if (!roleMsg) return;
|
|
650
|
+
|
|
651
|
+
const key = `${execution.sessionId}:${roleMsg.id}`;
|
|
652
|
+
const finalContent = this.sessionMsgContent.get(key) ?? roleMsg.content;
|
|
653
|
+
this.sessionMsgContent.delete(key);
|
|
654
|
+
|
|
655
|
+
updateSessionMessage(execution.sessionId, roleMsg.id, {
|
|
656
|
+
content: finalContent,
|
|
657
|
+
status,
|
|
658
|
+
...(result && {
|
|
659
|
+
turns: result.turns,
|
|
660
|
+
tokens: result.totalTokens,
|
|
661
|
+
}),
|
|
662
|
+
...(execution.knowledgeDebt && execution.knowledgeDebt.length > 0 && {
|
|
663
|
+
knowledgeDebt: execution.knowledgeDebt.map(d => ({ type: d.type, file: d.file, message: d.message })),
|
|
664
|
+
}),
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// Mark session as done in session-store (persisted to file)
|
|
668
|
+
// Skip CEO supervisor sessions — they stay active for wave lifecycle
|
|
669
|
+
if (session.roleId !== 'ceo' || session.source !== 'wave') {
|
|
670
|
+
updateSession(execution.sessionId, { status: 'done' });
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
private cleanupOrphanedChildren(parentSessionId: string): void {
|
|
675
|
+
for (const exec of this.executions.values()) {
|
|
676
|
+
if (exec.parentSessionId === parentSessionId && exec.status === 'awaiting_input') {
|
|
677
|
+
exec.status = 'done';
|
|
678
|
+
exec.stream.emit('msg:done', exec.roleId, {
|
|
679
|
+
output: '[Auto-closed] Parent session completed',
|
|
680
|
+
turns: 0,
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Get children of a parent session that are still running.
|
|
688
|
+
* Public for supervisor heartbeat done-guard (Principle 5).
|
|
689
|
+
*/
|
|
690
|
+
getRunningChildren(parentSessionId: string): Execution[] {
|
|
691
|
+
const running: Execution[] = [];
|
|
692
|
+
for (const exec of this.executions.values()) {
|
|
693
|
+
if (exec.parentSessionId === parentSessionId && exec.status === 'running') {
|
|
694
|
+
running.push(exec);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return running;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* SV: Crash Recovery — C-Level이 죽었는데 부하가 아직 실행 중이면 자동 재시작.
|
|
702
|
+
* "죽으면 오히려 이거하라고 다시 깨우는거야" (CEO 결정, 2026-03-14)
|
|
703
|
+
*/
|
|
704
|
+
private attemptSupervisionRecovery(deadExecution: Execution): void {
|
|
705
|
+
const runningChildren = this.getRunningChildren(deadExecution.sessionId);
|
|
706
|
+
if (runningChildren.length === 0) return;
|
|
707
|
+
|
|
708
|
+
// Only restart C-Level roles (CTO, CBO etc.)
|
|
709
|
+
// Resolve preset from wave file for correct org tree
|
|
710
|
+
let recoveryPresetId: string | undefined;
|
|
711
|
+
const deadSession = getSession(deadExecution.sessionId);
|
|
712
|
+
if (deadSession?.waveId) {
|
|
713
|
+
try {
|
|
714
|
+
const wp = path.join(COMPANY_ROOT, '.tycono', 'waves', `${deadSession.waveId}.json`);
|
|
715
|
+
if (fs.existsSync(wp)) {
|
|
716
|
+
recoveryPresetId = JSON.parse(fs.readFileSync(wp, 'utf-8')).preset;
|
|
717
|
+
}
|
|
718
|
+
} catch { /* ignore */ }
|
|
719
|
+
}
|
|
720
|
+
const orgTree = buildOrgTree(COMPANY_ROOT, recoveryPresetId);
|
|
721
|
+
const node = orgTree.nodes.get(deadExecution.roleId);
|
|
722
|
+
if (!node || node.level !== 'c-level') return;
|
|
723
|
+
|
|
724
|
+
const childSummary = runningChildren.map(c =>
|
|
725
|
+
`- [${c.roleId}] Session: ${c.sessionId} | Task: ${c.task.slice(0, 150)}`
|
|
726
|
+
).join('\n');
|
|
727
|
+
|
|
728
|
+
const recoveryTask = `[SUPERVISION RECOVERY] Your previous session ended, but subordinates are still running.
|
|
729
|
+
|
|
730
|
+
Resume supervision immediately. These sessions are still active:
|
|
731
|
+
${childSummary}
|
|
732
|
+
|
|
733
|
+
Use supervision watch to monitor them:
|
|
734
|
+
python3 "$SUPERVISION_CMD" watch ${runningChildren.map(c => c.sessionId).join(',')} --duration 120
|
|
735
|
+
|
|
736
|
+
Your job: monitor progress, course-correct if needed, wait for completion, then compile results and report.`;
|
|
737
|
+
|
|
738
|
+
console.log(`[ExecMgr] Supervision recovery: ${deadExecution.roleId} died with ${runningChildren.length} running children. Restarting.`);
|
|
739
|
+
|
|
740
|
+
// Propagate waveId from the dead session
|
|
741
|
+
const deadSes = getSession(deadExecution.sessionId);
|
|
742
|
+
const waveId = deadSes?.waveId;
|
|
743
|
+
|
|
744
|
+
// Create new session for recovery
|
|
745
|
+
const newSession = createSession(deadExecution.roleId, {
|
|
746
|
+
mode: 'do',
|
|
747
|
+
source: 'wave',
|
|
748
|
+
...(waveId && { waveId }),
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
// Re-parent running children to the new session
|
|
752
|
+
for (const child of runningChildren) {
|
|
753
|
+
child.parentSessionId = newSession.id;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Start new execution
|
|
757
|
+
try {
|
|
758
|
+
this.startExecution({
|
|
759
|
+
type: 'assign',
|
|
760
|
+
roleId: deadExecution.roleId,
|
|
761
|
+
task: recoveryTask,
|
|
762
|
+
sourceRole: 'ceo',
|
|
763
|
+
sessionId: newSession.id,
|
|
764
|
+
targetRoles: deadExecution.targetRoles,
|
|
765
|
+
});
|
|
766
|
+
} catch (err) {
|
|
767
|
+
console.error(`[ExecMgr] Supervision recovery failed for ${deadExecution.roleId}:`, err);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
getExecution(id: string): Execution | undefined {
|
|
772
|
+
return this.executions.get(id);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
getActiveExecution(sessionId: string): Execution | undefined {
|
|
776
|
+
let active: Execution | undefined;
|
|
777
|
+
let latest: Execution | undefined;
|
|
778
|
+
for (const exec of this.executions.values()) {
|
|
779
|
+
if (exec.sessionId === sessionId) {
|
|
780
|
+
if (isExecActive(exec.status)) {
|
|
781
|
+
if (!active || exec.createdAt > active.createdAt) {
|
|
782
|
+
active = exec;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
if (!latest || exec.createdAt > latest.createdAt) {
|
|
786
|
+
latest = exec;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (active ?? latest) return active ?? latest;
|
|
791
|
+
|
|
792
|
+
return this.recoverExecutionFromStream(sessionId);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
listExecutions(filter?: { status?: ExecStatus; roleId?: string; active?: boolean }): Array<{
|
|
796
|
+
id: string;
|
|
797
|
+
type: ExecType;
|
|
798
|
+
roleId: string;
|
|
799
|
+
task: string;
|
|
800
|
+
status: ExecStatus;
|
|
801
|
+
parentSessionId?: string;
|
|
802
|
+
childSessionIds: string[];
|
|
803
|
+
createdAt: string;
|
|
804
|
+
targetRole?: string;
|
|
805
|
+
}> {
|
|
806
|
+
const result: Array<{
|
|
807
|
+
id: string;
|
|
808
|
+
type: ExecType;
|
|
809
|
+
roleId: string;
|
|
810
|
+
task: string;
|
|
811
|
+
status: ExecStatus;
|
|
812
|
+
parentSessionId?: string;
|
|
813
|
+
childSessionIds: string[];
|
|
814
|
+
createdAt: string;
|
|
815
|
+
targetRole?: string;
|
|
816
|
+
}> = [];
|
|
817
|
+
|
|
818
|
+
for (const exec of this.executions.values()) {
|
|
819
|
+
if (filter?.active && !isExecActive(exec.status)) continue;
|
|
820
|
+
if (filter?.status && exec.status !== filter.status) continue;
|
|
821
|
+
if (filter?.roleId && exec.roleId !== filter.roleId) continue;
|
|
822
|
+
result.push({
|
|
823
|
+
id: exec.id,
|
|
824
|
+
type: exec.type,
|
|
825
|
+
roleId: exec.roleId,
|
|
826
|
+
task: exec.task,
|
|
827
|
+
status: exec.status,
|
|
828
|
+
parentSessionId: exec.parentSessionId,
|
|
829
|
+
childSessionIds: exec.childSessionIds,
|
|
830
|
+
createdAt: exec.createdAt,
|
|
831
|
+
targetRole: exec.targetRole,
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
return result;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
abortSession(sessionId: string): boolean {
|
|
839
|
+
const exec = this.getActiveExecution(sessionId);
|
|
840
|
+
if (!exec || !isExecActive(exec.status)) return false;
|
|
841
|
+
|
|
842
|
+
if (exec.status === 'running') exec.abort();
|
|
843
|
+
exec.status = 'error';
|
|
844
|
+
exec.error = 'Aborted by user';
|
|
845
|
+
exec.stream.emit('msg:error', exec.roleId, { message: 'Aborted by user' });
|
|
846
|
+
return true;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** Also support aborting by execution ID for internal use */
|
|
850
|
+
abortExecution(execId: string): boolean {
|
|
851
|
+
const exec = this.executions.get(execId);
|
|
852
|
+
if (!exec || !isExecActive(exec.status)) return false;
|
|
853
|
+
|
|
854
|
+
if (exec.status === 'running') exec.abort();
|
|
855
|
+
exec.status = 'error';
|
|
856
|
+
exec.error = 'Aborted by user';
|
|
857
|
+
exec.stream.emit('msg:error', exec.roleId, { message: 'Aborted by user' });
|
|
858
|
+
return true;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
continueSession(sessionId: string, response: string, responderRole?: string): Execution | null {
|
|
862
|
+
const exec = this.getActiveExecution(sessionId);
|
|
863
|
+
if (!exec || (exec.status !== 'awaiting_input' && exec.status !== 'done')) return null;
|
|
864
|
+
|
|
865
|
+
const isFollowUp = exec.status === 'done';
|
|
866
|
+
const effectiveResponder = responderRole ?? exec.targetRole ?? 'ceo';
|
|
867
|
+
|
|
868
|
+
exec.status = 'done';
|
|
869
|
+
exec.stream.emit('msg:reply', exec.roleId, { response, responderRole: effectiveResponder, isFollowUp });
|
|
870
|
+
|
|
871
|
+
const prevOutput = exec.result?.output ?? '';
|
|
872
|
+
const hasCliSession = !!exec.cliSessionId;
|
|
873
|
+
|
|
874
|
+
const responderLabel = effectiveResponder === 'ceo' ? 'CEO' : effectiveResponder.toUpperCase();
|
|
875
|
+
let continuationTask: string;
|
|
876
|
+
if (hasCliSession) {
|
|
877
|
+
// --resume preserves full conversation context — no need to repeat output
|
|
878
|
+
continuationTask = isFollowUp
|
|
879
|
+
? `[CEO Follow-up Directive]\n${response}`
|
|
880
|
+
: `[${responderLabel} Response — continue where you left off]\n${response}`;
|
|
881
|
+
} else {
|
|
882
|
+
const contextSummary = prevOutput.length > 2000
|
|
883
|
+
? prevOutput.slice(-2000)
|
|
884
|
+
: prevOutput;
|
|
885
|
+
continuationTask = isFollowUp
|
|
886
|
+
? `[CEO Follow-up Directive]\n${response}\n\n[Previous context — your earlier report follows]\n${contextSummary}`
|
|
887
|
+
: `[Continuation — previous output follows]\n${contextSummary}\n\n[${responderLabel} Response]\n${response}`;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const newExec = this.startExecution({
|
|
891
|
+
type: exec.type,
|
|
892
|
+
roleId: exec.roleId,
|
|
893
|
+
task: continuationTask,
|
|
894
|
+
sourceRole: effectiveResponder,
|
|
895
|
+
parentSessionId: exec.parentSessionId,
|
|
896
|
+
isContinuation: !isFollowUp,
|
|
897
|
+
sessionId: exec.sessionId, // Same session → same stream
|
|
898
|
+
// Pass CLI session ID for --resume (preserves Claude conversation context)
|
|
899
|
+
cliSessionId: exec.cliSessionId,
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
return newExec;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
getActiveExecutionForRole(roleId: string): Execution | undefined {
|
|
906
|
+
for (const exec of this.executions.values()) {
|
|
907
|
+
if (exec.roleId === roleId && isExecActive(exec.status)) {
|
|
908
|
+
return exec;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return undefined;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
private recoverExecutionFromStream(sessionId: string): Execution | undefined {
|
|
915
|
+
try {
|
|
916
|
+
// Try reading directly from session-keyed stream file
|
|
917
|
+
if (ActivityStream.exists(sessionId)) {
|
|
918
|
+
const events = ActivityStream.readAll(sessionId);
|
|
919
|
+
const startEvent = events.find(e => e.type === 'msg:start');
|
|
920
|
+
if (startEvent) {
|
|
921
|
+
return this.reconstructExecution(sessionId, sessionId, events, startEvent);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// Fallback: scan all stream files
|
|
926
|
+
const streamIds = ActivityStream.listAll();
|
|
927
|
+
let bestExec: { streamId: string; roleId: string; task: string; type: ExecType; status: ExecStatus; createdAt: string; output?: string } | undefined;
|
|
928
|
+
|
|
929
|
+
for (const streamId of streamIds) {
|
|
930
|
+
if (this.executions.has(streamId)) continue;
|
|
931
|
+
|
|
932
|
+
const events = ActivityStream.readAll(streamId);
|
|
933
|
+
const startEvent = events.find(e => e.type === 'msg:start');
|
|
934
|
+
if (!startEvent || (startEvent.data.sessionId as string) !== sessionId) continue;
|
|
935
|
+
|
|
936
|
+
const doneEvent = events.find(e => e.type === 'msg:done');
|
|
937
|
+
const errorEvent = events.find(e => e.type === 'msg:error');
|
|
938
|
+
const awaitingEvent = events.find(e => e.type === 'msg:awaiting_input');
|
|
939
|
+
const status: ExecStatus = awaitingEvent && !doneEvent ? 'awaiting_input'
|
|
940
|
+
: doneEvent ? 'done'
|
|
941
|
+
: errorEvent ? 'error'
|
|
942
|
+
: 'running'; // No done/error event = still running
|
|
943
|
+
|
|
944
|
+
const candidate = {
|
|
945
|
+
streamId,
|
|
946
|
+
roleId: startEvent.roleId,
|
|
947
|
+
task: startEvent.data.task as string ?? '',
|
|
948
|
+
type: (startEvent.data.type as string ?? 'assign') as ExecType,
|
|
949
|
+
status,
|
|
950
|
+
createdAt: startEvent.ts,
|
|
951
|
+
output: doneEvent?.data?.output as string | undefined,
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
if (!bestExec || candidate.createdAt > bestExec.createdAt) {
|
|
955
|
+
bestExec = candidate;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
if (!bestExec) return undefined;
|
|
960
|
+
|
|
961
|
+
const stream = ActivityStream.getOrCreate(sessionId, bestExec.roleId);
|
|
962
|
+
const execution: Execution = {
|
|
963
|
+
id: `recovered-${bestExec.streamId}`,
|
|
964
|
+
sessionId,
|
|
965
|
+
type: bestExec.type,
|
|
966
|
+
roleId: bestExec.roleId,
|
|
967
|
+
task: bestExec.task,
|
|
968
|
+
status: bestExec.status,
|
|
969
|
+
stream,
|
|
970
|
+
abort: () => {},
|
|
971
|
+
childSessionIds: [],
|
|
972
|
+
createdAt: bestExec.createdAt,
|
|
973
|
+
result: bestExec.output ? { output: bestExec.output, turns: 0, totalTokens: { input: 0, output: 0 }, toolCalls: [], dispatches: [] } : undefined,
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
this.executions.set(execution.id, execution);
|
|
977
|
+
console.log(`[ExecMgr] Recovered execution for session ${sessionId} (status: ${execution.status})`);
|
|
978
|
+
|
|
979
|
+
// OOM prevention: auto-cleanup recovered executions (they're only needed briefly for replay)
|
|
980
|
+
if (execution.status === 'done' || execution.status === 'error') {
|
|
981
|
+
setTimeout(() => {
|
|
982
|
+
this.executions.delete(execution.id);
|
|
983
|
+
execution.stream.close();
|
|
984
|
+
}, 30_000).unref();
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
return execution;
|
|
988
|
+
} catch (err) {
|
|
989
|
+
console.warn(`[ExecMgr] Failed to recover execution from streams:`, err);
|
|
990
|
+
return undefined;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
private reconstructExecution(
|
|
995
|
+
sessionId: string,
|
|
996
|
+
_streamId: string,
|
|
997
|
+
events: ActivityEvent[],
|
|
998
|
+
startEvent: ActivityEvent,
|
|
999
|
+
): Execution {
|
|
1000
|
+
const doneEvent = events.find(e => e.type === 'msg:done');
|
|
1001
|
+
const errorEvent = events.find(e => e.type === 'msg:error');
|
|
1002
|
+
const awaitingEvent = events.find(e => e.type === 'msg:awaiting_input');
|
|
1003
|
+
const status: ExecStatus = awaitingEvent && !doneEvent ? 'awaiting_input'
|
|
1004
|
+
: doneEvent ? 'done'
|
|
1005
|
+
: errorEvent ? 'error'
|
|
1006
|
+
: 'running'; // No done/error event = still running
|
|
1007
|
+
|
|
1008
|
+
const stream = ActivityStream.getOrCreate(sessionId, startEvent.roleId);
|
|
1009
|
+
const execution: Execution = {
|
|
1010
|
+
id: `recovered-${sessionId}`,
|
|
1011
|
+
sessionId,
|
|
1012
|
+
type: (startEvent.data.type as string ?? 'assign') as ExecType,
|
|
1013
|
+
roleId: startEvent.roleId,
|
|
1014
|
+
task: startEvent.data.task as string ?? '',
|
|
1015
|
+
status,
|
|
1016
|
+
stream,
|
|
1017
|
+
abort: () => {},
|
|
1018
|
+
childSessionIds: [],
|
|
1019
|
+
createdAt: startEvent.ts,
|
|
1020
|
+
result: doneEvent?.data?.output
|
|
1021
|
+
? { output: doneEvent.data.output as string, turns: 0, totalTokens: { input: 0, output: 0 }, toolCalls: [], dispatches: [] }
|
|
1022
|
+
: undefined,
|
|
1023
|
+
};
|
|
1024
|
+
|
|
1025
|
+
this.executions.set(execution.id, execution);
|
|
1026
|
+
console.log(`[ExecMgr] Recovered execution for session ${sessionId} (status: ${execution.status})`);
|
|
1027
|
+
return execution;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/* ─── Export singleton ───────────────────── */
|
|
1032
|
+
|
|
1033
|
+
export const executionManager = new ExecutionManager();
|
|
1034
|
+
|
|
1035
|
+
/** Backward-compat alias for gradual migration */
|
|
1036
|
+
export const jobManager = executionManager;
|