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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tycono",
3
- "version": "0.1.48",
3
+ "version": "0.1.50",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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): void {
400
- res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
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();