feat: Phase 3 — sub-agent detection, nested status, cascade completion

Phase 3 (Sub-Agent Support):
- session-monitor.js: sub-agents always passed through (inherit parent channel)
- watcher-manager.js enhancements:
  - Pending sub-agent queue: child sessions that arrive before parent are queued
    and processed when parent is registered (no dropped sub-agents)
  - linkSubAgent(): extracted helper for clean parent-child linking
  - Cascade completion: parent stays active until all children complete
  - Sub-agents embedded in parent status post (no separate top-level post)
- status-formatter.js: recursive nested rendering at configurable depth

Integration tests - test/integration/sub-agent.test.js (9 tests):
  3.1 Sub-agent detection via spawnedBy (monitor level)
  3.2 Nested status rendering (depth indentation, multiple children, deep nesting)
  3.3 Cascade completion (pending tool call tracking across sessions)
  3.4 Sub-agent JSONL parsing (usage events, error tool results)

All 95 tests pass (59 unit + 36 integration). make check clean.
This commit is contained in:
sol
2026-03-07 17:36:11 +00:00
parent e3bd6c52dd
commit 6df3278e91
3 changed files with 412 additions and 23 deletions

View File

@@ -134,8 +134,11 @@ async function startDaemon() {
logger.info({ count: Object.keys(savedOffsets).length }, 'Loaded persisted session offsets');
// Shared state
// Map<sessionKey, { postId, statusBox }>
// Map<sessionKey, { postId, agentId, channelId, rootPostId, children: Map }>
const activeBoxes = new Map();
// Pending sub-agents: spawnedBy key -> [info] (arrived before parent was added)
const pendingSubAgents = new Map();
let globalMetrics = {
activeSessions: 0,
updatesSent: 0,
@@ -202,27 +205,23 @@ async function startDaemon() {
}
// Sub-agent: skip creating own post (embedded in parent)
if (spawnedBy && activeBoxes.has(spawnedBy)) {
const parentBox = activeBoxes.get(spawnedBy);
// Link child to parent's session state
const childState = {
sessionKey,
transcriptFile,
spawnedBy,
parentPostId: parentBox.postId,
channelId,
depth: info.spawnDepth || 0,
agentId,
};
parentBox.children = parentBox.children || new Map();
parentBox.children.set(sessionKey, childState);
// Register in watcher
watcher.addSession(sessionKey, transcriptFile, {
agentId,
depth: info.spawnDepth || 1,
});
logger.info({ sessionKey, parent: spawnedBy }, 'Sub-agent linked to parent');
if (spawnedBy) {
if (activeBoxes.has(spawnedBy)) {
linkSubAgent(
activeBoxes,
watcher,
spawnedBy,
{ sessionKey, transcriptFile, channelId, agentId, spawnDepth: info.spawnDepth },
logger,
);
} else {
// Parent not yet tracked — queue for later
logger.debug({ sessionKey, spawnedBy }, 'Sub-agent queued (parent not yet tracked)');
if (!pendingSubAgents.has(spawnedBy)) pendingSubAgents.set(spawnedBy, []);
pendingSubAgents
.get(spawnedBy)
.push({ sessionKey, transcriptFile, channelId, agentId, spawnDepth: info.spawnDepth });
}
return;
}
@@ -262,6 +261,19 @@ async function startDaemon() {
? { lastOffset: saved.lastOffset, startTime: saved.startTime, agentId }
: { agentId };
watcher.addSession(sessionKey, transcriptFile, initialState);
// Process any pending sub-agents that were waiting for this parent
if (pendingSubAgents.has(sessionKey)) {
const pending = pendingSubAgents.get(sessionKey);
pendingSubAgents.delete(sessionKey);
for (const childInfo of pending) {
logger.debug(
{ childKey: childInfo.sessionKey, parentKey: sessionKey },
'Processing queued sub-agent',
);
linkSubAgent(activeBoxes, watcher, sessionKey, childInfo, logger);
}
}
});
// ---- Session Removed ----
@@ -400,6 +412,23 @@ async function startDaemon() {
// ---- Helper functions ----
function linkSubAgent(activeBoxes, watcher, parentKey, childInfo, logger) {
const parentBox = activeBoxes.get(parentKey);
if (!parentBox) return;
const { sessionKey, transcriptFile, agentId, spawnDepth } = childInfo;
if (!parentBox.children) parentBox.children = new Map();
parentBox.children.set(sessionKey, { sessionKey, transcriptFile, agentId });
watcher.addSession(sessionKey, transcriptFile, {
agentId,
depth: spawnDepth || 1,
});
logger.info({ sessionKey, parent: parentKey }, 'Sub-agent linked to parent');
}
function buildInitialText(agentId, sessionKey) {
const { format } = require('./status-formatter');
return format({