@@ -70,6 +70,9 @@ export interface DescendantAgentTaskInfo {
7070
7171type AgentTaskWorkspaceEntry = WorkspaceConfigEntry & { projectPath : string } ;
7272
73+ const COMPLETED_REPORT_CACHE_TTL_MS = 60 * 60 * 1000 ; // 1 hour
74+ const COMPLETED_REPORT_CACHE_MAX_ENTRIES = 128 ;
75+
7376interface PendingTaskWaiter {
7477 createdAt : number ;
7578 resolve : ( report : { reportMarkdown : string ; title ?: string } ) => void ;
@@ -155,7 +158,7 @@ export class TaskService {
155158 private readonly pendingStartWaitersByTaskId = new Map < string , PendingTaskStartWaiter [ ] > ( ) ;
156159 private readonly completedReportsByTaskId = new Map <
157160 string ,
158- { reportMarkdown : string ; title ?: string }
161+ { reportMarkdown : string ; title ?: string ; expiresAtMs : number }
159162 > ( ) ;
160163 private readonly remindedAwaitingReport = new Set < string > ( ) ;
161164
@@ -676,7 +679,11 @@ export class TaskService {
676679
677680 const cached = this . completedReportsByTaskId . get ( taskId ) ;
678681 if ( cached ) {
679- return Promise . resolve ( cached ) ;
682+ const nowMs = Date . now ( ) ;
683+ if ( cached . expiresAtMs > nowMs ) {
684+ return Promise . resolve ( { reportMarkdown : cached . reportMarkdown , title : cached . title } ) ;
685+ }
686+ this . completedReportsByTaskId . delete ( taskId ) ;
680687 }
681688
682689 const timeoutMs = options ?. timeoutMs ?? 10 * 60 * 1000 ; // 10 minutes
@@ -1074,38 +1081,58 @@ export class TaskService {
10741081 private async maybeStartQueuedTasks ( ) : Promise < void > {
10751082 await using _lock = await this . mutex . acquire ( ) ;
10761083
1077- const config = this . config . loadConfigOrDefault ( ) ;
1078- const taskSettings : TaskSettings = config . taskSettings ?? DEFAULT_TASK_SETTINGS ;
1084+ const configAtStart = this . config . loadConfigOrDefault ( ) ;
1085+ const taskSettingsAtStart : TaskSettings = configAtStart . taskSettings ?? DEFAULT_TASK_SETTINGS ;
10791086
1080- const activeCount = this . countActiveAgentTasks ( config ) ;
1081- const availableSlots = Math . max ( 0 , taskSettings . maxParallelAgentTasks - activeCount ) ;
1087+ const activeCount = this . countActiveAgentTasks ( configAtStart ) ;
1088+ const availableSlots = Math . max ( 0 , taskSettingsAtStart . maxParallelAgentTasks - activeCount ) ;
10821089 taskQueueDebug ( "TaskService.maybeStartQueuedTasks summary" , {
10831090 activeCount,
1084- maxParallelAgentTasks : taskSettings . maxParallelAgentTasks ,
1091+ maxParallelAgentTasks : taskSettingsAtStart . maxParallelAgentTasks ,
10851092 availableSlots,
10861093 } ) ;
10871094 if ( availableSlots === 0 ) return ;
10881095
1089- const queued = this . listAgentTaskWorkspaces ( config )
1090- . filter ( ( t ) => t . taskStatus === "queued" )
1096+ const queuedTaskIds = this . listAgentTaskWorkspaces ( configAtStart )
1097+ . filter ( ( t ) => t . taskStatus === "queued" && typeof t . id === "string" )
10911098 . sort ( ( a , b ) => {
10921099 const aTime = a . createdAt ? Date . parse ( a . createdAt ) : 0 ;
10931100 const bTime = b . createdAt ? Date . parse ( b . createdAt ) : 0 ;
10941101 return aTime - bTime ;
1095- } ) ;
1102+ } )
1103+ . map ( ( t ) => t . id ! ) ;
10961104
10971105 taskQueueDebug ( "TaskService.maybeStartQueuedTasks candidates" , {
1098- queuedCount : queued . length ,
1099- queuedIds : queued . map ( ( t ) => t . id ) . filter ( ( id ) : id is string => typeof id === "string" ) ,
1106+ queuedCount : queuedTaskIds . length ,
1107+ queuedIds : queuedTaskIds ,
11001108 } ) ;
11011109
1102- let startedCount = 0 ;
1103- for ( const task of queued ) {
1104- if ( startedCount >= availableSlots ) {
1110+ for ( const taskId of queuedTaskIds ) {
1111+ const config = this . config . loadConfigOrDefault ( ) ;
1112+ const taskSettings : TaskSettings = config . taskSettings ?? DEFAULT_TASK_SETTINGS ;
1113+ assert (
1114+ Number . isFinite ( taskSettings . maxParallelAgentTasks ) && taskSettings . maxParallelAgentTasks > 0 ,
1115+ "TaskService.maybeStartQueuedTasks: maxParallelAgentTasks must be a positive number"
1116+ ) ;
1117+
1118+ const activeCount = this . countActiveAgentTasks ( config ) ;
1119+ if ( activeCount >= taskSettings . maxParallelAgentTasks ) {
11051120 break ;
11061121 }
1107- if ( ! task . id ) continue ;
1108- const taskId = task . id ;
1122+
1123+ const taskEntry = this . findWorkspaceEntry ( config , taskId ) ;
1124+ if ( ! taskEntry ?. workspace . parentWorkspaceId ) continue ;
1125+ const task = taskEntry . workspace ;
1126+ if ( task . taskStatus !== "queued" ) continue ;
1127+
1128+ // Defensive: tasks can begin streaming before taskStatus flips to "running".
1129+ if ( this . aiService . isStreaming ( taskId ) ) {
1130+ taskQueueDebug ( "TaskService.maybeStartQueuedTasks queued-but-streaming; marking running" , {
1131+ taskId,
1132+ } ) ;
1133+ await this . setTaskStatus ( taskId , "running" ) ;
1134+ continue ;
1135+ }
11091136
11101137 assert ( typeof task . name === "string" && task . name . trim ( ) . length > 0 , "Task name missing" ) ;
11111138
@@ -1137,13 +1164,13 @@ export class TaskService {
11371164 }
11381165
11391166 const runtime = createRuntime ( runtimeConfig , {
1140- projectPath : task . projectPath ,
1167+ projectPath : taskEntry . projectPath ,
11411168 } ) ;
11421169
11431170 const workspaceName = task . name . trim ( ) ;
11441171 let workspacePath =
11451172 coerceNonEmptyString ( task . path ) ??
1146- runtime . getWorkspacePath ( task . projectPath , workspaceName ) ;
1173+ runtime . getWorkspacePath ( taskEntry . projectPath , workspaceName ) ;
11471174
11481175 let workspaceExists = false ;
11491176 try {
@@ -1158,6 +1185,20 @@ export class TaskService {
11581185 ? null
11591186 : await this . initStateManager . readInitStatus ( taskId ) ;
11601187
1188+ // Re-check capacity after awaiting IO to avoid dequeuing work (worktree creation/init) when
1189+ // another task became active in the meantime.
1190+ const latestConfig = this . config . loadConfigOrDefault ( ) ;
1191+ const latestTaskSettings : TaskSettings = latestConfig . taskSettings ?? DEFAULT_TASK_SETTINGS ;
1192+ const latestActiveCount = this . countActiveAgentTasks ( latestConfig ) ;
1193+ if ( latestActiveCount >= latestTaskSettings . maxParallelAgentTasks ) {
1194+ taskQueueDebug ( "TaskService.maybeStartQueuedTasks became full mid-loop" , {
1195+ taskId,
1196+ activeCount : latestActiveCount ,
1197+ maxParallelAgentTasks : latestTaskSettings . maxParallelAgentTasks ,
1198+ } ) ;
1199+ break ;
1200+ }
1201+
11611202 // Ensure the workspace exists before starting. Queued tasks should not create worktrees/directories
11621203 // until they are actually dequeued.
11631204 let trunkBranch =
@@ -1172,7 +1213,7 @@ export class TaskService {
11721213 let initLogger : InitLogger | null = null ;
11731214 const getInitLogger = ( ) : InitLogger => {
11741215 if ( initLogger ) return initLogger ;
1175- initLogger = this . startWorkspaceInit ( taskId , task . projectPath ) ;
1216+ initLogger = this . startWorkspaceInit ( taskId , taskEntry . projectPath ) ;
11761217 return initLogger ;
11771218 } ;
11781219
@@ -1196,7 +1237,7 @@ export class TaskService {
11961237 const initLogger = getInitLogger ( ) ;
11971238
11981239 const forkResult = await runtime . forkWorkspace ( {
1199- projectPath : task . projectPath ,
1240+ projectPath : taskEntry . projectPath ,
12001241 sourceWorkspaceName : parentWorkspaceName ,
12011242 newWorkspaceName : workspaceName ,
12021243 initLogger,
@@ -1206,7 +1247,7 @@ export class TaskService {
12061247 const createResult : WorkspaceCreationResult = forkResult . success
12071248 ? { success : true as const , workspacePath : forkResult . workspacePath }
12081249 : await runtime . createWorkspace ( {
1209- projectPath : task . projectPath ,
1250+ projectPath : taskEntry . projectPath ,
12101251 branchName : workspaceName ,
12111252 trunkBranch,
12121253 directoryName : workspaceName ,
@@ -1258,7 +1299,7 @@ export class TaskService {
12581299 } ) ;
12591300 void runtime
12601301 . initWorkspace ( {
1261- projectPath : task . projectPath ,
1302+ projectPath : taskEntry . projectPath ,
12621303 branchName : workspaceName ,
12631304 trunkBranch,
12641305 workspacePath,
@@ -1316,7 +1357,6 @@ export class TaskService {
13161357
13171358 await this . setTaskStatus ( taskId , "running" ) ;
13181359 taskQueueDebug ( "TaskService.maybeStartQueuedTasks started" , { taskId } ) ;
1319- startedCount += 1 ;
13201360 }
13211361 }
13221362
@@ -1613,8 +1653,31 @@ export class TaskService {
16131653 }
16141654 }
16151655
1656+ private cleanupExpiredCompletedReports ( nowMs = Date . now ( ) ) : void {
1657+ for ( const [ taskId , entry ] of this . completedReportsByTaskId ) {
1658+ if ( entry . expiresAtMs <= nowMs ) {
1659+ this . completedReportsByTaskId . delete ( taskId ) ;
1660+ }
1661+ }
1662+ }
1663+
1664+ private enforceCompletedReportCacheLimit ( ) : void {
1665+ while ( this . completedReportsByTaskId . size > COMPLETED_REPORT_CACHE_MAX_ENTRIES ) {
1666+ const first = this . completedReportsByTaskId . keys ( ) . next ( ) ;
1667+ if ( first . done ) break ;
1668+ this . completedReportsByTaskId . delete ( first . value ) ;
1669+ }
1670+ }
1671+
16161672 private resolveWaiters ( taskId : string , report : { reportMarkdown : string ; title ?: string } ) : void {
1617- this . completedReportsByTaskId . set ( taskId , report ) ;
1673+ const nowMs = Date . now ( ) ;
1674+ this . cleanupExpiredCompletedReports ( nowMs ) ;
1675+ this . completedReportsByTaskId . set ( taskId , {
1676+ reportMarkdown : report . reportMarkdown ,
1677+ title : report . title ,
1678+ expiresAtMs : nowMs + COMPLETED_REPORT_CACHE_TTL_MS ,
1679+ } ) ;
1680+ this . enforceCompletedReportCacheLimit ( ) ;
16181681
16191682 const waiters = this . pendingWaitersByTaskId . get ( taskId ) ;
16201683 if ( ! waiters || waiters . length === 0 ) {
@@ -1850,32 +1913,48 @@ export class TaskService {
18501913 }
18511914
18521915 private async cleanupReportedLeafTask ( workspaceId : string ) : Promise < void > {
1853- const config = this . config . loadConfigOrDefault ( ) ;
1854- const entry = this . findWorkspaceEntry ( config , workspaceId ) ;
1855- if ( ! entry ) return ;
1916+ assert ( workspaceId . length > 0 , "cleanupReportedLeafTask: workspaceId must be non-empty" ) ;
1917+
1918+ let currentWorkspaceId = workspaceId ;
1919+ const visited = new Set < string > ( ) ;
1920+ for ( let depth = 0 ; depth < 32 ; depth ++ ) {
1921+ if ( visited . has ( currentWorkspaceId ) ) {
1922+ log . error ( "cleanupReportedLeafTask: possible parentWorkspaceId cycle" , {
1923+ workspaceId : currentWorkspaceId ,
1924+ } ) ;
1925+ return ;
1926+ }
1927+ visited . add ( currentWorkspaceId ) ;
18561928
1857- const ws = entry . workspace ;
1858- if ( ! ws . parentWorkspaceId ) return ;
1859- if ( ws . taskStatus !== "reported" ) return ;
1929+ const config = this . config . loadConfigOrDefault ( ) ;
1930+ const entry = this . findWorkspaceEntry ( config , currentWorkspaceId ) ;
1931+ if ( ! entry ) return ;
18601932
1861- const hasChildren = this . listAgentTaskWorkspaces ( config ) . some (
1862- ( t ) => t . parentWorkspaceId === workspaceId
1863- ) ;
1864- if ( hasChildren ) {
1865- return ;
1866- }
1933+ const ws = entry . workspace ;
1934+ const parentWorkspaceId = ws . parentWorkspaceId ;
1935+ if ( ! parentWorkspaceId ) return ;
1936+ if ( ws . taskStatus !== "reported" ) return ;
18671937
1868- const removeResult = await this . workspaceService . remove ( workspaceId , true ) ;
1869- if ( ! removeResult . success ) {
1870- log . error ( "Failed to auto-delete reported task workspace" , {
1871- workspaceId,
1872- error : removeResult . error ,
1873- } ) ;
1874- return ;
1938+ const hasChildren = this . listAgentTaskWorkspaces ( config ) . some (
1939+ ( t ) => t . parentWorkspaceId === currentWorkspaceId
1940+ ) ;
1941+ if ( hasChildren ) return ;
1942+
1943+ const removeResult = await this . workspaceService . remove ( currentWorkspaceId , true ) ;
1944+ if ( ! removeResult . success ) {
1945+ log . error ( "Failed to auto-delete reported task workspace" , {
1946+ workspaceId : currentWorkspaceId ,
1947+ error : removeResult . error ,
1948+ } ) ;
1949+ return ;
1950+ }
1951+
1952+ currentWorkspaceId = parentWorkspaceId ;
18751953 }
18761954
1877- // Recursively attempt cleanup on parent if it's also a reported agent task.
1878- await this . cleanupReportedLeafTask ( ws . parentWorkspaceId ) ;
1955+ log . error ( "cleanupReportedLeafTask: exceeded max parent traversal depth" , {
1956+ workspaceId,
1957+ } ) ;
18791958 }
18801959
18811960 private findWorkspaceEntry (
0 commit comments