From 0f6e71063293586feadbfbe76e3190240fc0f049 Mon Sep 17 00:00:00 2001 From: WhitePepperLambSoup <251834947+WhitePepperLambSoup@users.noreply.github.com> Date: Tue, 3 Nov 2026 03:29:00 +1100 Subject: [PATCH 1/3] fix(swarm): restore the lead's team task list when a session is resumed After the CLI process of a lead session restarts, nothing sets the leader's team name again, so getTaskListId() falls back to the session id and TaskList cannot see the team's tasks. Look for the team whose leadSessionId is the resumed session and set it as the leader's team name again. runHeadless does this for --resume and --continue when agent teams are enabled. Closes #216 --- CHANGELOG.md | 1 + src/cli/print.ts | 6 ++ src/utils/swarm/reconnection.test.ts | 91 ++++++++++++++++++++++++++++ src/utils/swarm/reconnection.ts | 31 ++++++++++ 4 files changed, 129 insertions(+) create mode 100644 src/utils/swarm/reconnection.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index af75c77c..1e277870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to DreamCoder will be documented in this file. - Confirmation dialogs keep long workspace paths inside their bounds, so Cancel and Enable remain visible when enabling bypass permissions (#226). Thanks @imshah-id. - Images attached from uploads, paste or the built-in browser are kept unchanged within the client limits and resized or compressed when they exceed 2000×2000 pixels or 3.75 MB; a re-encoded image is named after its new type. An image the app cannot decode is attached as it is when it is within 3.75 MB. A file that cannot be attached is named in a message, and the other files picked or dropped with it are still attached. Sent images now use compact thumbnails that still open in the gallery (#218). Thanks @henricop. +- A lead session that is resumed after a restart keeps using its team's task list (#216). The lead looked for tasks in a list named after its session, so `TaskList` returned nothing and new tasks went to a different list than the one the teammates read. The lead now goes back to the team it created when the session is resumed. ## [0.8.0] — 2026-11-01 diff --git a/src/cli/print.ts b/src/cli/print.ts index dae86924..292d49d6 100644 --- a/src/cli/print.ts +++ b/src/cli/print.ts @@ -344,6 +344,8 @@ import { markMessagesAsRead, isShutdownApproved, } from '../utils/teammateMailbox.js' +import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js' +import { restoreLeaderTeamName } from '../utils/swarm/reconnection.js' import { removeTeammateFromTeamFile } from '../utils/swarm/teamHelpers.js' import { unassignTeammateTasks } from '../utils/tasks.js' import { getRunningTasks } from '../utils/task/framework.js' @@ -735,6 +737,10 @@ export async function runHeadless( return } + if ((options.continue || options.resume) && isAgentSwarmsEnabled()) { + restoreLeaderTeamName() + } + // Handle --rewind-files: restore filesystem and exit immediately if (options.rewindFiles) { // File history snapshots are only created for user messages, diff --git a/src/utils/swarm/reconnection.test.ts b/src/utils/swarm/reconnection.test.ts new file mode 100644 index 00000000..9fa190a4 --- /dev/null +++ b/src/utils/swarm/reconnection.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { getSessionId } from '../../bootstrap/state.js' +import { + clearLeaderTeamName, + createTask, + getTaskListId, + listTasks, + type Task, +} from '../tasks.js' +import { restoreLeaderTeamName } from './reconnection.js' + +const configDirAtLoad = process.env.DREAMCODER_CONFIG_DIR +let tmpDir: string + +const pendingTask = (subject: string): Omit => ({ + subject, + description: '', + status: 'pending', + blocks: [], + blockedBy: [], +}) + +async function writeTeam( + name: string, + leadSessionId: string, + createdAt = 1, +): Promise { + const teamDir = path.join(tmpDir, 'teams', name) + await fs.mkdir(teamDir, { recursive: true }) + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name, + createdAt, + leadAgentId: `team-lead@${name}`, + leadSessionId, + members: [], + }), + ) +} + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'reconnection-')) + process.env.DREAMCODER_CONFIG_DIR = tmpDir +}) + +afterEach(async () => { + clearLeaderTeamName() + process.env.DREAMCODER_CONFIG_DIR = configDirAtLoad + await fs.rm(tmpDir, { recursive: true, force: true }) +}) + +describe('restoreLeaderTeamName', () => { + it('points the lead back at the team it created before a restart', async () => { + await writeTeam('keep-test', getSessionId()) + await createTask('keep-test', pendingTask('Write the intro')) + expect(getTaskListId()).toBe(getSessionId()) + + restoreLeaderTeamName() + + expect(getTaskListId()).toBe('keep-test') + const tasks = await listTasks(getTaskListId()) + expect(tasks.map(task => task.subject)).toEqual(['Write the intro']) + }) + + it('ignores teams led by another session', async () => { + await writeTeam('someone-elses', 'another-session') + + restoreLeaderTeamName() + + expect(getTaskListId()).toBe(getSessionId()) + }) + + it('uses the newest team when the session led more than one', async () => { + await writeTeam('older', getSessionId(), 1) + await writeTeam('newer', getSessionId(), 2) + + restoreLeaderTeamName() + + expect(getTaskListId()).toBe('newer') + }) + + it('does nothing when no team has been created', () => { + restoreLeaderTeamName() + + expect(getTaskListId()).toBe(getSessionId()) + }) +}) diff --git a/src/utils/swarm/reconnection.ts b/src/utils/swarm/reconnection.ts index 62a38cc9..4b4ee2d1 100644 --- a/src/utils/swarm/reconnection.ts +++ b/src/utils/swarm/reconnection.ts @@ -6,9 +6,14 @@ * - Resumed sessions: Initialize from teamName/agentName stored in the transcript */ +import { readdirSync } from 'fs' +import { getSessionId } from '../../bootstrap/state.js' import type { AppState } from '../../state/AppState.js' import { logForDebugging } from '../debug.js' +import { getTeamsDir } from '../envUtils.js' +import { errorMessage, getErrnoCode } from '../errors.js' import { logError } from '../log.js' +import { setLeaderTeamName } from '../tasks.js' import { getDynamicTeamContext } from '../teammate.js' import { getTeamFilePath, readTeamFile } from './teamHelpers.js' @@ -65,6 +70,32 @@ export function computeInitialTeamContext(): } } +export function restoreLeaderTeamName(): void { + let teamNames: string[] + try { + teamNames = readdirSync(getTeamsDir()) + } catch (e) { + if (getErrnoCode(e) !== 'ENOENT') { + logForDebugging(`[Reconnection] Failed to list teams: ${errorMessage(e)}`) + } + return + } + + const sessionId = getSessionId() + let newest: { name: string; createdAt: number } | undefined + for (const name of teamNames) { + const teamFile = readTeamFile(name) + if (teamFile?.leadSessionId !== sessionId) continue + if (!newest || teamFile.createdAt > newest.createdAt) { + newest = { name, createdAt: teamFile.createdAt } + } + } + + if (newest) { + setLeaderTeamName(newest.name) + } +} + /** * Initialize teammate context from a resumed session. * From 6e46cd8d8fb80788eb0569d3680e5a7d188ad1e0 Mon Sep 17 00:00:00 2001 From: WhitePepperLambSoup <251834947+WhitePepperLambSoup@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:16:06 +1100 Subject: [PATCH 2/3] refactor(swarm): gate the lead's team restore in a tested function restoreLeaderTeamNameOnResume() holds the agent-teams check, runHeadless calls it, and the tests cover the enabled, disabled and forked-resume cases. --- src/cli/print.ts | 7 ++-- src/utils/swarm/reconnection.test.ts | 51 +++++++++++++++++++++++++++- src/utils/swarm/reconnection.ts | 6 ++++ 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/cli/print.ts b/src/cli/print.ts index 292d49d6..df0185a2 100644 --- a/src/cli/print.ts +++ b/src/cli/print.ts @@ -344,8 +344,7 @@ import { markMessagesAsRead, isShutdownApproved, } from '../utils/teammateMailbox.js' -import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js' -import { restoreLeaderTeamName } from '../utils/swarm/reconnection.js' +import { restoreLeaderTeamNameOnResume } from '../utils/swarm/reconnection.js' import { removeTeammateFromTeamFile } from '../utils/swarm/teamHelpers.js' import { unassignTeammateTasks } from '../utils/tasks.js' import { getRunningTasks } from '../utils/task/framework.js' @@ -737,8 +736,8 @@ export async function runHeadless( return } - if ((options.continue || options.resume) && isAgentSwarmsEnabled()) { - restoreLeaderTeamName() + if (options.continue || options.resume) { + restoreLeaderTeamNameOnResume() } // Handle --rewind-files: restore filesystem and exit immediately diff --git a/src/utils/swarm/reconnection.test.ts b/src/utils/swarm/reconnection.test.ts index 9fa190a4..0c58b69c 100644 --- a/src/utils/swarm/reconnection.test.ts +++ b/src/utils/swarm/reconnection.test.ts @@ -10,11 +10,21 @@ import { listTasks, type Task, } from '../tasks.js' -import { restoreLeaderTeamName } from './reconnection.js' +import { + restoreLeaderTeamName, + restoreLeaderTeamNameOnResume, +} from './reconnection.js' const configDirAtLoad = process.env.DREAMCODER_CONFIG_DIR +const userTypeAtLoad = process.env.USER_TYPE +const agentTeamsAtLoad = process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS let tmpDir: string +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) delete process.env[name] + else process.env[name] = value +} + const pendingTask = (subject: string): Omit => ({ subject, description: '', @@ -89,3 +99,42 @@ describe('restoreLeaderTeamName', () => { expect(getTaskListId()).toBe(getSessionId()) }) }) + +describe('restoreLeaderTeamNameOnResume', () => { + beforeEach(() => { + delete process.env.USER_TYPE + delete process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS + }) + + afterEach(() => { + restoreEnv('USER_TYPE', userTypeAtLoad) + restoreEnv('CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS', agentTeamsAtLoad) + }) + + it('restores the team name when agent teams are enabled', async () => { + process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1' + await writeTeam('keep-test', getSessionId()) + + restoreLeaderTeamNameOnResume() + + expect(getTaskListId()).toBe('keep-test') + }) + + it('restores nothing when agent teams are disabled', async () => { + await writeTeam('keep-test', getSessionId()) + + restoreLeaderTeamNameOnResume() + + expect(getTaskListId()).toBe(getSessionId()) + }) + + it('does not take over the team of the session it was forked from', async () => { + process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1' + await writeTeam('original-team', 'original-session') + expect(getSessionId()).not.toBe('original-session') + + restoreLeaderTeamNameOnResume() + + expect(getTaskListId()).toBe(getSessionId()) + }) +}) diff --git a/src/utils/swarm/reconnection.ts b/src/utils/swarm/reconnection.ts index 4b4ee2d1..87b2478b 100644 --- a/src/utils/swarm/reconnection.ts +++ b/src/utils/swarm/reconnection.ts @@ -9,6 +9,7 @@ import { readdirSync } from 'fs' import { getSessionId } from '../../bootstrap/state.js' import type { AppState } from '../../state/AppState.js' +import { isAgentSwarmsEnabled } from '../agentSwarmsEnabled.js' import { logForDebugging } from '../debug.js' import { getTeamsDir } from '../envUtils.js' import { errorMessage, getErrnoCode } from '../errors.js' @@ -96,6 +97,11 @@ export function restoreLeaderTeamName(): void { } } +export function restoreLeaderTeamNameOnResume(): void { + if (!isAgentSwarmsEnabled()) return + restoreLeaderTeamName() +} + /** * Initialize teammate context from a resumed session. * From 57374a6f72ab579280dbd6bece9fb30fc473e6f5 Mon Sep 17 00:00:00 2001 From: Diao Shengjia <104132148+GoDiao@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:33:59 +0800 Subject: [PATCH 3/3] docs(changelog): thank @WhitePepperLambSoup --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e277870..e7185bb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ All notable changes to DreamCoder will be documented in this file. - Confirmation dialogs keep long workspace paths inside their bounds, so Cancel and Enable remain visible when enabling bypass permissions (#226). Thanks @imshah-id. - Images attached from uploads, paste or the built-in browser are kept unchanged within the client limits and resized or compressed when they exceed 2000×2000 pixels or 3.75 MB; a re-encoded image is named after its new type. An image the app cannot decode is attached as it is when it is within 3.75 MB. A file that cannot be attached is named in a message, and the other files picked or dropped with it are still attached. Sent images now use compact thumbnails that still open in the gallery (#218). Thanks @henricop. -- A lead session that is resumed after a restart keeps using its team's task list (#216). The lead looked for tasks in a list named after its session, so `TaskList` returned nothing and new tasks went to a different list than the one the teammates read. The lead now goes back to the team it created when the session is resumed. +- A lead session that is resumed after a restart keeps using its team's task list (#216). The lead looked for tasks in a list named after its session, so `TaskList` returned nothing and new tasks went to a different list than the one the teammates read. The lead now goes back to the team it created when the session is resumed. Thanks @WhitePepperLambSoup. ## [0.8.0] — 2026-11-01