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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user