teamshare-bridge 0.21.57 → 0.21.58

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.
@@ -6,6 +6,7 @@ exports.runDaemon = runDaemon;
6
6
  * agent and spawns a session when a `wake` event arrives (contract section 4).
7
7
  * Also serves the localhost probe so the website can detect it.
8
8
  */
9
+ const node_child_process_1 = require("node:child_process");
9
10
  const node_fs_1 = require("node:fs");
10
11
  const process_tree_1 = require("../lib/process-tree");
11
12
  const git_runner_1 = require("../lib/git/git-runner");
@@ -25,6 +26,12 @@ const rate_gate_1 = require("../lib/rate-gate");
25
26
  const harness_models_1 = require("../lib/harness/harness-models");
26
27
  /** IMP-917: wakes already deferred once for a rate-limit pause (bounded). */
27
28
  const rateDeferred = new Set();
29
+ /**
30
+ * Spawn watchdog hook: assigned by runDaemon to `scheduleSpawnWatch` so the
31
+ * module-level handleWake can flag every real spawn for session-start
32
+ * verification (a spawned session that never came up used to fail silently).
33
+ */
34
+ let spawnWatchScheduler = null;
28
35
  const bus_1 = require("../lib/workspace/bus");
29
36
  const presence_1 = require("../lib/workspace/presence");
30
37
  const worktree_1 = require("../lib/orchestrator/worktree");
@@ -459,38 +466,151 @@ async function runDaemon(portOverride) {
459
466
  if (config.agents.length === 0) {
460
467
  log('no agents configured - run: teamshare-agent connect --agent <id> --key ts_...');
461
468
  }
469
+ // ── Spawn watchdog (2026-09) ────────────────────────────────────────────
470
+ // A spawned session that never reaches session:start used to fail in
471
+ // complete silence (Windows Script Host blocked, native-module ABI crash
472
+ // after a Node upgrade, missing/stale CLI build): only a [spawn] line in
473
+ // daemon.log, no comment, no hub signal. 20s after a wake reports 'spawned',
474
+ // verify a session actually came up and report a spawn_failure diagnostic
475
+ // with the capture-file tail when it did not.
476
+ const spawnWatchTimers = new Map();
477
+ function scheduleSpawnWatch(agentId, wakeType) {
478
+ const prev = spawnWatchTimers.get(agentId);
479
+ if (prev)
480
+ clearTimeout(prev);
481
+ const startedAt = Date.now();
482
+ const timer = setTimeout(() => {
483
+ spawnWatchTimers.delete(agentId);
484
+ try {
485
+ if (sessionRelay.current(agentId))
486
+ return; // session came up
487
+ const lock = (0, lock_1.readLock)(agentId);
488
+ if (lock?.pid) {
489
+ try {
490
+ process.kill(lock.pid, 0);
491
+ return; // a live session process holds the lock - assume running
492
+ }
493
+ catch {
494
+ /* dead pid - fall through to forensics */
495
+ }
496
+ }
497
+ // No session, no live lock: look for a capture file written by this
498
+ // spawn (hidden-launch creates it only if the chain actually ran).
499
+ let launched = false;
500
+ let captureInfo = 'no capture file - the hidden launcher never ran (Windows Script Host/wscript.exe may be disabled or blocked by antivirus)';
501
+ const dir = (0, node_path_1.join)((0, node_os_1.homedir)(), '.teamshare', 'sessions');
502
+ let newest = null;
503
+ for (const f of (0, node_fs_1.readdirSync)(dir)) {
504
+ if (!f.startsWith('spawn-') || !f.endsWith('.log'))
505
+ continue;
506
+ const st = (0, node_fs_1.statSync)((0, node_path_1.join)(dir, f), { throwIfNoEntry: false });
507
+ if (!st)
508
+ continue;
509
+ if (st.mtimeMs >= startedAt - 2_000 &&
510
+ (!newest || st.mtimeMs > newest.mtime)) {
511
+ newest = { file: f, mtime: st.mtimeMs };
512
+ }
513
+ }
514
+ if (newest) {
515
+ launched = true;
516
+ const content = (0, node_fs_1.readFileSync)((0, node_path_1.join)(dir, newest.file), 'utf8');
517
+ const tail = content
518
+ .split(/\r?\n/)
519
+ .filter(Boolean)
520
+ .slice(-15)
521
+ .join('\n');
522
+ captureInfo = tail
523
+ ? `capture ${newest.file} tail:\n${tail}`
524
+ : `capture ${newest.file} exists but is empty - the CLI exited before logging anything (native-module load failure?)`;
525
+ }
526
+ log(`agent ${agentId}: spawn watch - no session started ${Math.round((Date.now() - startedAt) / 1000)}s after ${wakeType} wake`);
527
+ (0, diagnostics_1.reportDiagnosticSafe)({
528
+ kind: 'spawn_failure',
529
+ severity: 'error',
530
+ agentId,
531
+ message: `Session never started after a ${wakeType} wake (${launched ? 'CLI exited before session:start' : 'hidden launcher never ran'})`,
532
+ errorCode: launched
533
+ ? 'SESSION_PREMATURE_EXIT'
534
+ : 'SESSION_NEVER_LAUNCHED',
535
+ context: { wakeType, capture: captureInfo.slice(0, 4_000) },
536
+ includeDoctor: true,
537
+ });
538
+ }
539
+ catch (err) {
540
+ log(`agent ${agentId}: spawn watch failed: ${(0, config_1.msg)(err)}`);
541
+ }
542
+ }, 20_000);
543
+ timer.unref();
544
+ spawnWatchTimers.set(agentId, timer);
545
+ }
546
+ spawnWatchScheduler = scheduleSpawnWatch;
462
547
  // ── Stale lock sweep (safety net) ────────────────────────────────────
463
548
  // On startup + every 5 minutes, scan all agent lock files. If the
464
- // holder PID is dead, remove the lock. This catches orphaned locks from
465
- // force-killed sessions, crashes, OOM kills, or daemon restarts.
466
- // NEVER touches a lock held by a live process — only dead PIDs.
549
+ // holder PID is dead OR has been recycled by a foreign (non-session)
550
+ // process, remove the lock. This catches orphaned locks from force-killed
551
+ // sessions, crashes, OOM kills, daemon restarts, and PID reuse (which
552
+ // used to keep the agent "busy" for up to 24h, queueing every wake).
553
+ // NEVER touches a lock held by a live session-like process.
554
+ function holderLooksLikeSession(pid) {
555
+ try {
556
+ if (process.platform === 'win32') {
557
+ const out = (0, node_child_process_1.spawnSync)('tasklist', ['/FI', `PID eq ${pid}`, '/NH'], {
558
+ encoding: 'utf8',
559
+ windowsHide: true,
560
+ timeout: 3_000,
561
+ });
562
+ const text = (out.stdout ?? '').trim();
563
+ // No match / odd output - stay conservative (trust the pid).
564
+ if (!text || /^INFO:/i.test(text))
565
+ return true;
566
+ return /node|wscript|teamshare|opencode|claude|conhost|bun\.exe/i.test(text);
567
+ }
568
+ const out = (0, node_child_process_1.spawnSync)('ps', ['-p', String(pid), '-o', 'comm='], {
569
+ encoding: 'utf8',
570
+ timeout: 3_000,
571
+ });
572
+ const comm = (out.stdout ?? '').trim().toLowerCase();
573
+ if (!comm)
574
+ return true; // ps unavailable - stay conservative
575
+ return /node|bun|deno|teamshare/.test(comm);
576
+ }
577
+ catch {
578
+ return true;
579
+ }
580
+ }
467
581
  function sweepStaleLocks() {
468
582
  for (const agent of config.agents) {
469
583
  const lock = (0, lock_1.readLock)(agent.agentId);
470
584
  if (!lock)
471
585
  continue;
586
+ let reason = null;
472
587
  try {
473
588
  process.kill(lock.pid, 0); // probe — throws ESRCH if dead
589
+ if (!holderLooksLikeSession(lock.pid)) {
590
+ reason = `pid ${lock.pid} is now a foreign process (PID reuse)`;
591
+ }
474
592
  }
475
593
  catch {
476
- // PID is dead — remove the orphaned lock.
477
- (0, lock_1.clearLock)(agent.agentId);
478
- log(`lock sweep: cleared stale lock for ${agent.agentId.slice(0, 8)} (dead pid ${lock.pid})`);
479
- // Also end the active session for this agent so the backend marks
480
- // it as ended immediately rather than waiting for the heartbeat
481
- // sweep. Without this, the daemon's own heartbeat keeps
482
- // lastHeartbeatAt fresh and the backend sweep skips the session.
483
- const activeSessionId = sessionRelay.current(agent.agentId);
484
- if (activeSessionId) {
485
- sessionRelay.sessionEnd({ sessionId: activeSessionId, exitCode: 1 });
486
- log(`lock sweep: ended session ${activeSessionId.slice(0, 12)} for agent ${agent.agentId.slice(0, 8)}`);
487
- }
488
- // Push busy=false immediately so the frontend indicator updates
489
- // without waiting for the next 5-min pushState interval.
490
- const handle = handles.get(agent.agentId);
491
- if (handle)
492
- handle.pushState();
594
+ reason = `dead pid ${lock.pid}`;
493
595
  }
596
+ if (!reason)
597
+ continue;
598
+ (0, lock_1.clearLock)(agent.agentId);
599
+ log(`lock sweep: cleared stale lock for ${agent.agentId.slice(0, 8)} (${reason})`);
600
+ // Also end the active session for this agent so the backend marks
601
+ // it as ended immediately rather than waiting for the heartbeat
602
+ // sweep. Without this, the daemon's own heartbeat keeps
603
+ // lastHeartbeatAt fresh and the backend sweep skips the session.
604
+ const activeSessionId = sessionRelay.current(agent.agentId);
605
+ if (activeSessionId) {
606
+ sessionRelay.sessionEnd({ sessionId: activeSessionId, exitCode: 1 });
607
+ log(`lock sweep: ended session ${activeSessionId.slice(0, 12)} for agent ${agent.agentId.slice(0, 8)}`);
608
+ }
609
+ // Push busy=false immediately so the frontend indicator updates
610
+ // without waiting for the next 5-min pushState interval.
611
+ const handle = handles.get(agent.agentId);
612
+ if (handle)
613
+ handle.pushState();
494
614
  }
495
615
  }
496
616
  // Run immediately on startup.
@@ -600,6 +720,10 @@ async function runDaemon(portOverride) {
600
720
  queued: agentState.queued ?? 0,
601
721
  machineId: (0, config_1.machineId)(),
602
722
  machineLabel: (0, config_1.machineLabel)(),
723
+ // DETERMINISTIC PORT DISCOVERY (2026-09): the website reads this via
724
+ // GET /agents/:id/state and probes the EXACT bound port on 127.0.0.1
725
+ // instead of only scanning the static candidate set.
726
+ port: boundPort,
603
727
  projects: Object.fromEntries(Object.entries(status.projects).map(([id, link]) => [id, { path: link.path, guard: link.guard ?? true }])),
604
728
  };
605
729
  const snapshot = JSON.stringify(payload);
@@ -806,8 +930,11 @@ async function runDaemon(portOverride) {
806
930
  }
807
931
  }
808
932
  }
809
- catch {
810
- // best-effort: don't crash the daemon on periodic poll failure
933
+ catch (err) {
934
+ // best-effort: don't crash the daemon - but DO log it. This used
935
+ // to be a bare catch, so an inbox outage silently disabled the
936
+ // 20-minute recovery sweep with zero trace.
937
+ log(`[error] mention/stranded poll failed: ${(0, config_1.msg)(err)}`);
811
938
  }
812
939
  }, MENTION_POLL_MS));
813
940
  }
@@ -1006,6 +1133,12 @@ async function runDaemon(portOverride) {
1006
1133
  });
1007
1134
  socket.on('wake', (event) => {
1008
1135
  lastWakeByAgent.set(agent.agentId, new Date().toISOString());
1136
+ // Receipt ack (2026-09): tells the hub the emit actually landed, so a
1137
+ // half-open socket is caught by the hub's 15s watchdog instead of
1138
+ // swallowing the wake silently. Older hubs simply ignore the event.
1139
+ if (event.wakeId) {
1140
+ socket.emit('wake.received', { wakeId: event.wakeId });
1141
+ }
1009
1142
  // Manual interrupt (2026-08): session.stop bypasses cooldown, dedupe
1010
1143
  // and the queue entirely - a human pressing "Stop work" must always
1011
1144
  // land immediately. Kills the live session (harness/CLI and its
@@ -1288,7 +1421,12 @@ async function runDaemon(portOverride) {
1288
1421
  const agent = credsOf(h);
1289
1422
  void (async () => {
1290
1423
  const settings = await freshSettings(api_1.TeamshareApi.forAgent(apiBase, agent), agentId);
1291
- return spawnWake(api_1.TeamshareApi.forAgent(apiBase, agent), agentId, agent.apiKey, event, getConfig(), { settings, recentSpawns });
1424
+ const outcome = await spawnWake(api_1.TeamshareApi.forAgent(apiBase, agent), agentId, agent.apiKey, event, getConfig(), { settings, recentSpawns });
1425
+ // Same spawn watchdog as the live wake path - a queued wake that
1426
+ // spawns later must also be verified (session:start or report).
1427
+ if (outcome === 'spawned')
1428
+ spawnWatchScheduler?.(agentId, event.type);
1429
+ return outcome;
1292
1430
  })()
1293
1431
  .then(() => {
1294
1432
  log(`agent ${agentId}: spawned queued wake ${event.type}`);
@@ -1415,18 +1553,10 @@ async function handleWake(api, agentId, apiKey, event, configInput, extra) {
1415
1553
  log(`agent ${agentId}: wake ${event.type} skipped (quiet hours)`);
1416
1554
  return 'skipped';
1417
1555
  }
1418
- if (event.taskId && config.wakeRules?.priorities?.length) {
1419
- try {
1420
- const task = await api.getTask(event.taskId);
1421
- if (!config.wakeRules.priorities.includes(task.priority)) {
1422
- log(`agent ${agentId}: wake ${event.type} skipped (priority ${task.priority})`);
1423
- return 'skipped';
1424
- }
1425
- }
1426
- catch {
1427
- /* fetch failed - proceed anyway */
1428
- }
1429
- }
1556
+ // wakeRules.priorities enforcement REMOVED (2026-09): the gate was already
1557
+ // declared unsupported in the guides, yet a stale config.json entry still
1558
+ // silently skipped assignment wakes with zero feedback ("agent idle, no
1559
+ // response"). The config field is tolerated but ignored from now on.
1430
1560
  // Stop signals (2026-09): land IMMEDIATELY, before the busy queue. A
1431
1561
  // `build.stop` used to be queued behind the very build it targeted (the
1432
1562
  // build loop holds the agent lock for its whole run), so the stop file was
@@ -1447,9 +1577,29 @@ async function handleWake(api, agentId, apiKey, event, configInput, extra) {
1447
1577
  queue.push(event);
1448
1578
  wakeQueue.set(agentId, queue);
1449
1579
  log(`agent ${agentId}: wake ${event.type} queued (busy since ${lock.startedAt}, ${lock.kind}) - ${queue.length} waiting`);
1580
+ // Stuck-queue visibility: a lock held far longer than any real session
1581
+ // (PID reuse / hung process) queues every wake invisibly - the hub keeps
1582
+ // showing idle. Report once the lock crosses 10 minutes; the fingerprint
1583
+ // includes the lock's startedAt, so all wakes behind the SAME lock
1584
+ // coalesce into one report (10-min cooldown).
1585
+ const lockAgeMs = Date.now() - new Date(lock.startedAt).getTime();
1586
+ if (lockAgeMs > 10 * 60_000) {
1587
+ (0, diagnostics_1.reportDiagnosticSafe)({
1588
+ kind: 'wake_failure',
1589
+ severity: 'warning',
1590
+ agentId,
1591
+ errorCode: 'WAKE_QUEUE_STUCK',
1592
+ message: `wake ${event.type} queued behind a ${lock.kind} lock held over 10 minutes (since ${lock.startedAt}) - if no session is actually running: teamshare-bridge clear-lock --agent ${agentId}`,
1593
+ });
1594
+ }
1450
1595
  return 'queued';
1451
1596
  }
1452
- return await spawnWake(api, agentId, apiKey, event, config, { settings, recentSpawns, orchestratorClient });
1597
+ const outcome = await spawnWake(api, agentId, apiKey, event, config, { settings, recentSpawns, orchestratorClient });
1598
+ // Spawn watchdog: flag every real spawn so a session that never reaches
1599
+ // session:start is reported instead of failing in complete silence.
1600
+ if (outcome === 'spawned')
1601
+ spawnWatchScheduler?.(agentId, event.type);
1602
+ return outcome;
1453
1603
  }
1454
1604
  async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
1455
1605
  log(`agent ${agentId}: wake ${event.type}${event.taskId ? ` task=${event.taskId}` : ''}`);
@@ -1784,6 +1934,10 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
1784
1934
  log(`agent ${agentId}: question.answered -> orchestrator signaled for OT ${event.orchestratorTaskId}`);
1785
1935
  }
1786
1936
  // Also resume build mode if a build session was active
1937
+ // `spawned` is ONLY true when a process was actually launched - a
1938
+ // no-local-session answer must not claim 'spawned' (callers treat it as
1939
+ // a live spawn, and the spawn watchdog would false-positive).
1940
+ let spawned = false;
1787
1941
  if (hasBuildState) {
1788
1942
  // Write a signal file so the running build loop wakes immediately
1789
1943
  // instead of waiting for the 15s API poll fallback.
@@ -1795,6 +1949,7 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
1795
1949
  /* best-effort */
1796
1950
  }
1797
1951
  (0, spawn_1.spawnBuildSession)(agentId, apiKey, event.projectId, extra.settings, undefined, cwd);
1952
+ spawned = true;
1798
1953
  }
1799
1954
  else if (event.taskId) {
1800
1955
  // Resume-on-answer for EVERY mode: if THIS PC still holds the harness
@@ -1819,19 +1974,22 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
1819
1974
  if ((0, session_store_1.hasHarnessSession)(agentId, event.taskId, 'run')) {
1820
1975
  log(`agent ${agentId}: question.answered -> resuming run session for task ${event.taskId}`);
1821
1976
  (0, spawn_1.spawnAgentSession)(agentId, apiKey, event.taskId, extra.settings, undefined, cwd);
1977
+ spawned = true;
1822
1978
  }
1823
1979
  else if (planAllowed && (0, session_store_1.hasHarnessSession)(agentId, event.taskId, 'plan')) {
1824
1980
  log(`agent ${agentId}: question.answered -> resuming plan session for task ${event.taskId}`);
1825
1981
  (0, spawn_1.spawnPlanSession)(agentId, apiKey, event.taskId, event.projectId ?? undefined, extra.settings, undefined, cwd);
1982
+ spawned = true;
1826
1983
  }
1827
1984
  else if ((0, session_store_1.hasHarnessSession)(agentId, event.taskId, 'build')) {
1828
1985
  (0, spawn_1.spawnBuildSession)(agentId, apiKey, event.projectId, extra.settings, undefined, cwd);
1986
+ spawned = true;
1829
1987
  }
1830
1988
  else {
1831
1989
  log(`agent ${agentId}: question.answered - no local session for task ${event.taskId} (fresh on next run)`);
1832
1990
  }
1833
1991
  }
1834
- return 'spawned';
1992
+ return spawned ? 'spawned' : 'skipped';
1835
1993
  }
1836
1994
  if (event.type === 'question.expired') {
1837
1995
  // Expired-on-timeout: same wake path as question.answered — the agent
@@ -1848,6 +2006,9 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
1848
2006
  extra.orchestratorClient.signalQuestionAnswered(event.orchestratorTaskId, event.commentId ?? event.messageId ?? 'wake-expired');
1849
2007
  log(`agent ${agentId}: question.expired -> orchestrator signaled for OT ${event.orchestratorTaskId}`);
1850
2008
  }
2009
+ // Same spawn-tracking rule as question.answered: only claim 'spawned'
2010
+ // when a process was actually launched.
2011
+ let spawned = false;
1851
2012
  if (hasBuildState) {
1852
2013
  try {
1853
2014
  (0, node_fs_1.mkdirSync)(SIGNAL_DIR, { recursive: true });
@@ -1857,6 +2018,7 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
1857
2018
  /* best-effort */
1858
2019
  }
1859
2020
  (0, spawn_1.spawnBuildSession)(agentId, apiKey, event.projectId, extra.settings, undefined, cwd);
2021
+ spawned = true;
1860
2022
  }
1861
2023
  else if (event.taskId) {
1862
2024
  // Same resume-on-expiry path as question.answered (local session only).
@@ -1873,15 +2035,18 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
1873
2035
  const planAllowed = !isToolTask && workMode !== 'build';
1874
2036
  if ((0, session_store_1.hasHarnessSession)(agentId, event.taskId, 'run')) {
1875
2037
  (0, spawn_1.spawnAgentSession)(agentId, apiKey, event.taskId, extra.settings, undefined, cwd);
2038
+ spawned = true;
1876
2039
  }
1877
2040
  else if (planAllowed && (0, session_store_1.hasHarnessSession)(agentId, event.taskId, 'plan')) {
1878
2041
  (0, spawn_1.spawnPlanSession)(agentId, apiKey, event.taskId, event.projectId ?? undefined, extra.settings, undefined, cwd);
2042
+ spawned = true;
1879
2043
  }
1880
2044
  else if ((0, session_store_1.hasHarnessSession)(agentId, event.taskId, 'build')) {
1881
2045
  (0, spawn_1.spawnBuildSession)(agentId, apiKey, event.projectId, extra.settings, undefined, cwd);
2046
+ spawned = true;
1882
2047
  }
1883
2048
  }
1884
- return 'spawned';
2049
+ return spawned ? 'spawned' : 'skipped';
1885
2050
  }
1886
2051
  if (event.type === 'comment.mention') {
1887
2052
  // Comment mentions on tasks spawn a task session (agent responds in