diff --git a/CHANGELOG.md b/CHANGELOG.md index af75c77c..e7185bb7 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. Thanks @WhitePepperLambSoup. ## [0.8.0] — 2026-11-01 diff --git a/src/cli/print.ts b/src/cli/print.ts index dae86924..df0185a2 100644 --- a/src/cli/print.ts +++ b/src/cli/print.ts @@ -344,6 +344,7 @@ import { markMessagesAsRead, isShutdownApproved, } from '../utils/teammateMailbox.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' @@ -735,6 +736,10 @@ export async function runHeadless( return } + if (options.continue || options.resume) { + restoreLeaderTeamNameOnResume() + } + // 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..0c58b69c --- /dev/null +++ b/src/utils/swarm/reconnection.test.ts @@ -0,0 +1,140 @@ +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, + 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: '', + 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()) + }) +}) + +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 62a38cc9..87b2478b 100644 --- a/src/utils/swarm/reconnection.ts +++ b/src/utils/swarm/reconnection.ts @@ -6,9 +6,15 @@ * - 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 { isAgentSwarmsEnabled } from '../agentSwarmsEnabled.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 +71,37 @@ 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) + } +} + +export function restoreLeaderTeamNameOnResume(): void { + if (!isAgentSwarmsEnabled()) return + restoreLeaderTeamName() +} + /** * Initialize teammate context from a resumed session. *