teamshare-bridge 0.21.56 → 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.
- package/dist/bridge/daemon.js +231 -40
- package/dist/bridge/daemon.js.map +1 -1
- package/dist/bridge/doctor.d.ts +2 -0
- package/dist/bridge/doctor.js +258 -2
- package/dist/bridge/doctor.js.map +1 -1
- package/dist/bridge/index.js +28 -5
- package/dist/bridge/index.js.map +1 -1
- package/dist/bridge/spawn.js +10 -0
- package/dist/bridge/spawn.js.map +1 -1
- package/dist/lib/config.js +14 -5
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/diagnostics.d.ts +5 -1
- package/dist/lib/diagnostics.js +9 -0
- package/dist/lib/diagnostics.js.map +1 -1
- package/dist/lib/harness/session-store.d.ts +6 -4
- package/dist/lib/harness/session-store.js +7 -18
- package/dist/lib/harness/session-store.js.map +1 -1
- package/dist/lib/lock.js +3 -2
- package/dist/lib/lock.js.map +1 -1
- package/package.json +1 -1
package/dist/bridge/daemon.js
CHANGED
|
@@ -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
|
|
465
|
-
//
|
|
466
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
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
|
-
|
|
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,28 +1949,47 @@ 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
|
|
1801
1956
|
// session for the task, re-spawn the matching command so it continues
|
|
1802
1957
|
// from where it stopped. A daemon on another PC (or a cleared store)
|
|
1803
1958
|
// has no record here, so nothing is spawned - the next run is fresh.
|
|
1959
|
+
//
|
|
1960
|
+
// workMode-aware (2026-09): a task in build mode must NEVER resume a
|
|
1961
|
+
// plan session (the plan conversation opens with "PLAN MODE … must NOT
|
|
1962
|
+
// execute" and the model inherits that read-only claim). Tool/research
|
|
1963
|
+
// tasks are always build-mode and skip the plan branch entirely.
|
|
1964
|
+
let workMode;
|
|
1965
|
+
let taskKind;
|
|
1966
|
+
try {
|
|
1967
|
+
const t = await api.getTask(event.taskId);
|
|
1968
|
+
workMode = t.workMode;
|
|
1969
|
+
taskKind = t.kind;
|
|
1970
|
+
}
|
|
1971
|
+
catch { /* best-effort — fall through to session-only logic */ }
|
|
1972
|
+
const isToolTask = taskKind === 'research' || taskKind === 'tool';
|
|
1973
|
+
const planAllowed = !isToolTask && workMode !== 'build';
|
|
1804
1974
|
if ((0, session_store_1.hasHarnessSession)(agentId, event.taskId, 'run')) {
|
|
1805
1975
|
log(`agent ${agentId}: question.answered -> resuming run session for task ${event.taskId}`);
|
|
1806
1976
|
(0, spawn_1.spawnAgentSession)(agentId, apiKey, event.taskId, extra.settings, undefined, cwd);
|
|
1977
|
+
spawned = true;
|
|
1807
1978
|
}
|
|
1808
|
-
else if ((0, session_store_1.hasHarnessSession)(agentId, event.taskId, 'plan')) {
|
|
1979
|
+
else if (planAllowed && (0, session_store_1.hasHarnessSession)(agentId, event.taskId, 'plan')) {
|
|
1809
1980
|
log(`agent ${agentId}: question.answered -> resuming plan session for task ${event.taskId}`);
|
|
1810
1981
|
(0, spawn_1.spawnPlanSession)(agentId, apiKey, event.taskId, event.projectId ?? undefined, extra.settings, undefined, cwd);
|
|
1982
|
+
spawned = true;
|
|
1811
1983
|
}
|
|
1812
1984
|
else if ((0, session_store_1.hasHarnessSession)(agentId, event.taskId, 'build')) {
|
|
1813
1985
|
(0, spawn_1.spawnBuildSession)(agentId, apiKey, event.projectId, extra.settings, undefined, cwd);
|
|
1986
|
+
spawned = true;
|
|
1814
1987
|
}
|
|
1815
1988
|
else {
|
|
1816
1989
|
log(`agent ${agentId}: question.answered - no local session for task ${event.taskId} (fresh on next run)`);
|
|
1817
1990
|
}
|
|
1818
1991
|
}
|
|
1819
|
-
return 'spawned';
|
|
1992
|
+
return spawned ? 'spawned' : 'skipped';
|
|
1820
1993
|
}
|
|
1821
1994
|
if (event.type === 'question.expired') {
|
|
1822
1995
|
// Expired-on-timeout: same wake path as question.answered — the agent
|
|
@@ -1833,6 +2006,9 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
|
|
|
1833
2006
|
extra.orchestratorClient.signalQuestionAnswered(event.orchestratorTaskId, event.commentId ?? event.messageId ?? 'wake-expired');
|
|
1834
2007
|
log(`agent ${agentId}: question.expired -> orchestrator signaled for OT ${event.orchestratorTaskId}`);
|
|
1835
2008
|
}
|
|
2009
|
+
// Same spawn-tracking rule as question.answered: only claim 'spawned'
|
|
2010
|
+
// when a process was actually launched.
|
|
2011
|
+
let spawned = false;
|
|
1836
2012
|
if (hasBuildState) {
|
|
1837
2013
|
try {
|
|
1838
2014
|
(0, node_fs_1.mkdirSync)(SIGNAL_DIR, { recursive: true });
|
|
@@ -1842,20 +2018,35 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
|
|
|
1842
2018
|
/* best-effort */
|
|
1843
2019
|
}
|
|
1844
2020
|
(0, spawn_1.spawnBuildSession)(agentId, apiKey, event.projectId, extra.settings, undefined, cwd);
|
|
2021
|
+
spawned = true;
|
|
1845
2022
|
}
|
|
1846
2023
|
else if (event.taskId) {
|
|
1847
2024
|
// Same resume-on-expiry path as question.answered (local session only).
|
|
2025
|
+
// workMode-aware: build/tool tasks never resume a plan session.
|
|
2026
|
+
let workMode;
|
|
2027
|
+
let taskKind;
|
|
2028
|
+
try {
|
|
2029
|
+
const t = await api.getTask(event.taskId);
|
|
2030
|
+
workMode = t.workMode;
|
|
2031
|
+
taskKind = t.kind;
|
|
2032
|
+
}
|
|
2033
|
+
catch { /* best-effort */ }
|
|
2034
|
+
const isToolTask = taskKind === 'research' || taskKind === 'tool';
|
|
2035
|
+
const planAllowed = !isToolTask && workMode !== 'build';
|
|
1848
2036
|
if ((0, session_store_1.hasHarnessSession)(agentId, event.taskId, 'run')) {
|
|
1849
2037
|
(0, spawn_1.spawnAgentSession)(agentId, apiKey, event.taskId, extra.settings, undefined, cwd);
|
|
2038
|
+
spawned = true;
|
|
1850
2039
|
}
|
|
1851
|
-
else if ((0, session_store_1.hasHarnessSession)(agentId, event.taskId, 'plan')) {
|
|
2040
|
+
else if (planAllowed && (0, session_store_1.hasHarnessSession)(agentId, event.taskId, 'plan')) {
|
|
1852
2041
|
(0, spawn_1.spawnPlanSession)(agentId, apiKey, event.taskId, event.projectId ?? undefined, extra.settings, undefined, cwd);
|
|
2042
|
+
spawned = true;
|
|
1853
2043
|
}
|
|
1854
2044
|
else if ((0, session_store_1.hasHarnessSession)(agentId, event.taskId, 'build')) {
|
|
1855
2045
|
(0, spawn_1.spawnBuildSession)(agentId, apiKey, event.projectId, extra.settings, undefined, cwd);
|
|
2046
|
+
spawned = true;
|
|
1856
2047
|
}
|
|
1857
2048
|
}
|
|
1858
|
-
return 'spawned';
|
|
2049
|
+
return spawned ? 'spawned' : 'skipped';
|
|
1859
2050
|
}
|
|
1860
2051
|
if (event.type === 'comment.mention') {
|
|
1861
2052
|
// Comment mentions on tasks spawn a task session (agent responds in
|