tycono 0.1.48 → 0.1.50
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/package.json +1 -1
- package/src/api/src/engine/runners/claude-cli.ts +33 -0
- package/src/api/src/routes/execute.ts +67 -6
- package/src/web/dist/assets/{index-zlMk7zUk.js → index-gMRTdIIj.js} +30 -30
- package/src/web/dist/assets/{preview-app-qdXevJej.js → preview-app-CNfdunjF.js} +1 -1
- package/src/web/dist/index.html +1 -1
package/package.json
CHANGED
|
@@ -230,6 +230,32 @@ export class ClaudeCliRunner implements ExecutionRunner {
|
|
|
230
230
|
|
|
231
231
|
const promise = new Promise<RunnerResult>((resolve, reject) => {
|
|
232
232
|
let buffer = '';
|
|
233
|
+
let resolved = false;
|
|
234
|
+
let exitCode: number | null = null;
|
|
235
|
+
let exitSignal: string | null = null;
|
|
236
|
+
|
|
237
|
+
// Safety net: if 'exit' fires but 'close' doesn't follow within 5s,
|
|
238
|
+
// force resolve. This handles grandchild processes keeping stdout pipe open.
|
|
239
|
+
proc.on('exit', (code, signal) => {
|
|
240
|
+
exitCode = code;
|
|
241
|
+
exitSignal = signal ?? null;
|
|
242
|
+
setTimeout(() => {
|
|
243
|
+
if (!resolved) {
|
|
244
|
+
console.warn(`[Runner] Safety net: 'close' not fired 5s after 'exit' (code=${code}, signal=${signal}). Force resolving.`);
|
|
245
|
+
resolved = true;
|
|
246
|
+
try { fs.unlinkSync(promptFile); } catch { /* ignore */ }
|
|
247
|
+
try { fs.unlinkSync(dispatchScript); } catch { /* ignore */ }
|
|
248
|
+
try { fs.rmSync(runnerOutputDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
249
|
+
resolve({
|
|
250
|
+
output,
|
|
251
|
+
turns: turnCount || 1,
|
|
252
|
+
totalTokens: { input: totalInput, output: totalOutput },
|
|
253
|
+
toolCalls,
|
|
254
|
+
dispatches,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}, 5000);
|
|
258
|
+
});
|
|
233
259
|
|
|
234
260
|
proc.stdout.on('data', (data: Buffer) => {
|
|
235
261
|
buffer += data.toString();
|
|
@@ -283,6 +309,11 @@ export class ClaudeCliRunner implements ExecutionRunner {
|
|
|
283
309
|
});
|
|
284
310
|
|
|
285
311
|
proc.on('close', (code, signal) => {
|
|
312
|
+
if (resolved) {
|
|
313
|
+
console.log(`[Runner] 'close' fired after safety-net resolve (code=${code}, signal=${signal})`);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
resolved = true;
|
|
286
317
|
console.log(`[Runner] Done: code=${code}, signal=${signal}, output=${output.length}chars`);
|
|
287
318
|
// 버퍼에 남은 데이터 처리
|
|
288
319
|
if (buffer.trim()) {
|
|
@@ -327,6 +358,8 @@ export class ClaudeCliRunner implements ExecutionRunner {
|
|
|
327
358
|
});
|
|
328
359
|
|
|
329
360
|
proc.on('error', (err) => {
|
|
361
|
+
if (resolved) return;
|
|
362
|
+
resolved = true;
|
|
330
363
|
try { fs.unlinkSync(promptFile); } catch { /* ignore */ }
|
|
331
364
|
try { fs.unlinkSync(dispatchScript); } catch { /* ignore */ }
|
|
332
365
|
try { fs.rmSync(runnerOutputDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
@@ -396,8 +396,14 @@ function readBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
|
396
396
|
|
|
397
397
|
/* ─── SSE helpers ────────────────────────────── */
|
|
398
398
|
|
|
399
|
-
function sendSSE(res: ServerResponse, event: string, data: unknown):
|
|
400
|
-
res.
|
|
399
|
+
function sendSSE(res: ServerResponse, event: string, data: unknown): boolean {
|
|
400
|
+
if (res.destroyed || res.writableEnded) return false;
|
|
401
|
+
try {
|
|
402
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
403
|
+
return true;
|
|
404
|
+
} catch {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
401
407
|
}
|
|
402
408
|
|
|
403
409
|
function jsonResponse(res: ServerResponse, status: number, body: unknown): void {
|
|
@@ -405,6 +411,11 @@ function jsonResponse(res: ServerResponse, status: number, body: unknown): void
|
|
|
405
411
|
res.end(JSON.stringify(body));
|
|
406
412
|
}
|
|
407
413
|
|
|
414
|
+
/** SSE timeout: max duration for a single SSE connection (10 minutes) */
|
|
415
|
+
const SSE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
416
|
+
/** SSE heartbeat interval (15 seconds) */
|
|
417
|
+
const SSE_HEARTBEAT_MS = 15 * 1000;
|
|
418
|
+
|
|
408
419
|
function startSSE(res: ServerResponse): void {
|
|
409
420
|
res.writeHead(200, {
|
|
410
421
|
'Content-Type': 'text/event-stream',
|
|
@@ -414,6 +425,31 @@ function startSSE(res: ServerResponse): void {
|
|
|
414
425
|
});
|
|
415
426
|
}
|
|
416
427
|
|
|
428
|
+
/** Start SSE heartbeat + timeout. Returns cleanup function. */
|
|
429
|
+
function startSSELifecycle(res: ServerResponse, onTimeout: () => void): () => void {
|
|
430
|
+
const heartbeat = setInterval(() => {
|
|
431
|
+
if (res.destroyed || res.writableEnded) {
|
|
432
|
+
clearInterval(heartbeat);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
res.write(': heartbeat\n\n');
|
|
437
|
+
} catch {
|
|
438
|
+
clearInterval(heartbeat);
|
|
439
|
+
}
|
|
440
|
+
}, SSE_HEARTBEAT_MS);
|
|
441
|
+
|
|
442
|
+
const timeout = setTimeout(() => {
|
|
443
|
+
console.warn('[SSE] Connection timeout — forcing close');
|
|
444
|
+
onTimeout();
|
|
445
|
+
}, SSE_TIMEOUT_MS);
|
|
446
|
+
|
|
447
|
+
return () => {
|
|
448
|
+
clearInterval(heartbeat);
|
|
449
|
+
clearTimeout(timeout);
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
417
453
|
/* ─── POST /api/exec/assign ──────────────────── */
|
|
418
454
|
/* Now delegates to JobManager, streams events back via SSE for backward compat */
|
|
419
455
|
|
|
@@ -445,6 +481,13 @@ function handleAssign(body: Record<string, unknown>, req: IncomingMessage, res:
|
|
|
445
481
|
startSSE(res);
|
|
446
482
|
sendSSE(res, 'start', { id: job.id, roleId, task, sourceRole });
|
|
447
483
|
|
|
484
|
+
const cleanupLifecycle = startSSELifecycle(res, () => {
|
|
485
|
+
roleStatus.set(roleId, 'idle');
|
|
486
|
+
sendSSE(res, 'error', { message: 'SSE timeout — connection forcibly closed after 10 minutes' });
|
|
487
|
+
if (!res.writableEnded) res.end();
|
|
488
|
+
job.stream.unsubscribe(subscriber);
|
|
489
|
+
});
|
|
490
|
+
|
|
448
491
|
const subscriber = (event: ActivityEvent) => {
|
|
449
492
|
switch (event.type) {
|
|
450
493
|
case 'text':
|
|
@@ -466,15 +509,17 @@ function handleAssign(body: Record<string, unknown>, req: IncomingMessage, res:
|
|
|
466
509
|
sendSSE(res, 'stderr', { message: event.data.message });
|
|
467
510
|
break;
|
|
468
511
|
case 'job:done':
|
|
512
|
+
cleanupLifecycle();
|
|
469
513
|
roleStatus.set(roleId, 'idle');
|
|
470
514
|
sendSSE(res, 'done', event.data);
|
|
471
|
-
res.end();
|
|
515
|
+
if (!res.writableEnded) res.end();
|
|
472
516
|
job.stream.unsubscribe(subscriber);
|
|
473
517
|
break;
|
|
474
518
|
case 'job:error':
|
|
519
|
+
cleanupLifecycle();
|
|
475
520
|
roleStatus.set(roleId, 'idle');
|
|
476
521
|
sendSSE(res, 'error', { message: event.data.message });
|
|
477
|
-
res.end();
|
|
522
|
+
if (!res.writableEnded) res.end();
|
|
478
523
|
job.stream.unsubscribe(subscriber);
|
|
479
524
|
break;
|
|
480
525
|
}
|
|
@@ -484,6 +529,7 @@ function handleAssign(body: Record<string, unknown>, req: IncomingMessage, res:
|
|
|
484
529
|
|
|
485
530
|
// Client disconnect → unsubscribe only (job keeps running!)
|
|
486
531
|
req.on('close', () => {
|
|
532
|
+
cleanupLifecycle();
|
|
487
533
|
job.stream.unsubscribe(subscriber);
|
|
488
534
|
});
|
|
489
535
|
}
|
|
@@ -743,6 +789,18 @@ function handleSessionMessage(
|
|
|
743
789
|
startSSE(res);
|
|
744
790
|
sendSSE(res, 'session', { sessionId, ceoMessageId: ceoMsg.id, roleMessageId: roleMsg.id });
|
|
745
791
|
|
|
792
|
+
// SSE lifecycle: heartbeat keeps connection alive, timeout prevents stuck connections
|
|
793
|
+
const cleanupSSELifecycle = startSSELifecycle(res, () => {
|
|
794
|
+
// Timeout reached — force close the SSE connection
|
|
795
|
+
cleanupChildSubscriptions();
|
|
796
|
+
updateMessage(sessionId, roleMsg.id, { status: 'error' });
|
|
797
|
+
roleStatus.set(roleId, 'idle');
|
|
798
|
+
completeActivity(roleId);
|
|
799
|
+
sendSSE(res, 'error', { message: 'SSE timeout — connection forcibly closed after 10 minutes' });
|
|
800
|
+
if (!res.writableEnded) res.end();
|
|
801
|
+
handle.abort();
|
|
802
|
+
});
|
|
803
|
+
|
|
746
804
|
roleStatus.set(roleId, 'working');
|
|
747
805
|
setActivity(roleId, content.slice(0, 80));
|
|
748
806
|
|
|
@@ -842,6 +900,7 @@ function handleSessionMessage(
|
|
|
842
900
|
|
|
843
901
|
handle.promise
|
|
844
902
|
.then((result: RunnerResult) => {
|
|
903
|
+
cleanupSSELifecycle();
|
|
845
904
|
cleanupChildSubscriptions();
|
|
846
905
|
updateMessage(sessionId, roleMsg.id, { content: roleMsg.content, status: 'done' });
|
|
847
906
|
roleStatus.set(roleId, 'idle');
|
|
@@ -856,18 +915,20 @@ function handleSessionMessage(
|
|
|
856
915
|
turns: result.turns,
|
|
857
916
|
tokens: result.totalTokens,
|
|
858
917
|
});
|
|
859
|
-
res.end();
|
|
918
|
+
if (!res.writableEnded) res.end();
|
|
860
919
|
})
|
|
861
920
|
.catch((err: Error) => {
|
|
921
|
+
cleanupSSELifecycle();
|
|
862
922
|
cleanupChildSubscriptions();
|
|
863
923
|
updateMessage(sessionId, roleMsg.id, { status: 'error' });
|
|
864
924
|
roleStatus.set(roleId, 'idle');
|
|
865
925
|
completeActivity(roleId);
|
|
866
926
|
sendSSE(res, 'error', { message: err.message });
|
|
867
|
-
res.end();
|
|
927
|
+
if (!res.writableEnded) res.end();
|
|
868
928
|
});
|
|
869
929
|
|
|
870
930
|
req.on('close', () => {
|
|
931
|
+
cleanupSSELifecycle();
|
|
871
932
|
cleanupChildSubscriptions();
|
|
872
933
|
if (roleMsg.status === 'streaming') {
|
|
873
934
|
handle.abort();
|