@@ -66,13 +66,15 @@ function createTaskRegistryMaintenanceHarness(params: {
6666 cronStore ?: CronStoreFile ;
6767 cronRunLogEntries ?: Record < string , CronRunLogEntry [ ] > ;
6868 runtimeAuthoritative ?: boolean ;
69+ terminalSubagentRunEndedAt ?: Record < string , number > ;
6970} ) {
7071 const sessionStore = params . sessionStore ?? { } ;
7172 const acpEntry = params . acpEntry ;
7273 const activeCronJobIds = new Set ( params . activeCronJobIds ?? [ ] ) ;
7374 const activeRunIds = new Set ( params . activeRunIds ?? [ ] ) ;
7475 const activeAcpSessionKeys = new Set ( params . activeAcpSessionKeys ?? [ ] ) ;
7576 const cronRunLogEntries = params . cronRunLogEntries ?? { } ;
77+ const terminalSubagentRunEndedAt = params . terminalSubagentRunEndedAt ?? { } ;
7678 const currentTasks = new Map ( params . tasks . map ( ( task ) => [ task . taskId , { ...task } ] ) ) ;
7779
7880 const runtime : TaskRegistryMaintenanceRuntime = {
@@ -181,6 +183,7 @@ function createTaskRegistryMaintenanceHarness(params: {
181183 resolveCronJobsStorePath : ( ) => "/tmp/openclaw-test-cron/jobs.json" ,
182184 loadCronJobsStoreSync : ( ) => params . cronStore ?? { version : 1 , jobs : [ ] } ,
183185 readCronRunLogEntriesSync : ( { jobId } ) => ( jobId ? ( cronRunLogEntries [ jobId ] ?? [ ] ) : [ ] ) ,
186+ getSubagentRunEndedAt : ( runId : string ) => terminalSubagentRunEndedAt [ runId ] ,
184187 } ;
185188
186189 setTaskRegistryMaintenanceRuntimeForTests ( runtime ) ;
@@ -861,3 +864,153 @@ describe("task-registry maintenance issue #60299", () => {
861864 expect ( hookNow ) . toBeGreaterThanOrEqual ( beforeMaintenance ) ;
862865 } ) ;
863866} ) ;
867+
868+ describe ( "task-registry maintenance issue #90444" , ( ) => {
869+ it ( "marks a running subagent task lost when its in-memory run is terminal" , async ( ) => {
870+ // Regression: the kill path defers task-row finalization to maintenance to
871+ // avoid the kill-vs-complete race. Maintenance must detect terminal
872+ // in-memory subagent runs and clear their stuck running task rows.
873+ const runId = "run-killed-zombie-90444" ;
874+ const task = makeStaleTask ( {
875+ runtime : "subagent" ,
876+ runId,
877+ childSessionKey : "agent:main:subagent:zombie-90444" ,
878+ } ) ;
879+
880+ // Session store still has an entry (kill happened before session cleanup).
881+ const { currentTasks } = createTaskRegistryMaintenanceHarness ( {
882+ tasks : [ task ] ,
883+ sessionStore : {
884+ "agent:main:subagent:zombie-90444" : {
885+ sessionId : "sess-zombie-90444" ,
886+ updatedAt : Date . now ( ) ,
887+ } ,
888+ } ,
889+ // The in-memory run is terminal (endedAt set).
890+ terminalSubagentRunEndedAt : { [ runId ] : Date . now ( ) - 5000 } ,
891+ runtimeAuthoritative : true ,
892+ } ) ;
893+
894+ expectMaintenanceCounts ( await runTaskRegistryMaintenance ( ) , { reconciled : 1 } ) ;
895+ expectTaskStatus ( currentTasks , task . taskId , "lost" ) ;
896+ } ) ;
897+
898+ it ( "keeps a running subagent task live when its in-memory run has not ended" , async ( ) => {
899+ const runId = "run-active-subagent-90444" ;
900+ const task = makeStaleTask ( {
901+ runtime : "subagent" ,
902+ runId,
903+ childSessionKey : "agent:main:subagent:active-90444" ,
904+ } ) ;
905+
906+ const { currentTasks } = createTaskRegistryMaintenanceHarness ( {
907+ tasks : [ task ] ,
908+ sessionStore : {
909+ "agent:main:subagent:active-90444" : {
910+ sessionId : "sess-active-90444" ,
911+ updatedAt : Date . now ( ) ,
912+ } ,
913+ } ,
914+ // No endedAt in the terminal map → run is still live.
915+ terminalSubagentRunEndedAt : { } ,
916+ runtimeAuthoritative : true ,
917+ } ) ;
918+
919+ expectMaintenanceCounts ( await runTaskRegistryMaintenance ( ) , { reconciled : 0 } ) ;
920+ expectTaskStatus ( currentTasks , task . taskId , "running" ) ;
921+ } ) ;
922+
923+ it ( "marks a killed subagent task lost in non-authoritative (CLI maintenance) context" , async ( ) => {
924+ const runId = "run-nonauth-zombie-90444" ;
925+ const task = makeStaleTask ( {
926+ runtime : "subagent" ,
927+ runId,
928+ childSessionKey : "agent:main:subagent:nonauth-90444" ,
929+ } ) ;
930+
931+ // CLI maintenance reads endedAt from the SQLite-backed snapshot rather than
932+ // the process-local in-memory map, so it can finalize kills the gateway
933+ // persisted to SQLite even when isRuntimeAuthoritative() is false.
934+ const { currentTasks } = createTaskRegistryMaintenanceHarness ( {
935+ tasks : [ task ] ,
936+ sessionStore : {
937+ "agent:main:subagent:nonauth-90444" : {
938+ sessionId : "sess-nonauth-90444" ,
939+ updatedAt : Date . now ( ) ,
940+ } ,
941+ } ,
942+ terminalSubagentRunEndedAt : { [ runId ] : Date . now ( ) - 5000 } ,
943+ runtimeAuthoritative : false ,
944+ } ) ;
945+
946+ expectMaintenanceCounts ( await runTaskRegistryMaintenance ( ) , { reconciled : 1 } ) ;
947+ expectTaskStatus ( currentTasks , task . taskId , "lost" ) ;
948+ } ) ;
949+
950+ it ( "marks a freshly killed subagent task lost before the lost-grace window expires" , async ( ) => {
951+ // Regression for the timing gap ClawSweeper caught: the terminal-run check
952+ // must fire in shouldMarkLost before hasLostGraceExpired so a task killed
953+ // seconds ago is finalized on the next sweep, not after 5+ minutes.
954+ const now = Date . now ( ) ;
955+ const runId = "run-fresh-killed-90444" ;
956+ const task = makeStaleTask ( {
957+ runtime : "subagent" ,
958+ runId,
959+ childSessionKey : "agent:main:subagent:fresh-90444" ,
960+ // Fresh timestamps: task was created and killed 30 s ago, well within
961+ // the 5-minute TASK_RECONCILE_GRACE_MS window.
962+ createdAt : now - 30_000 ,
963+ startedAt : now - 30_000 ,
964+ lastEventAt : now - 30_000 ,
965+ } ) ;
966+
967+ const { currentTasks } = createTaskRegistryMaintenanceHarness ( {
968+ tasks : [ task ] ,
969+ sessionStore : {
970+ "agent:main:subagent:fresh-90444" : {
971+ sessionId : "sess-fresh-90444" ,
972+ updatedAt : now ,
973+ } ,
974+ } ,
975+ terminalSubagentRunEndedAt : { [ runId ] : now - 5_000 } ,
976+ runtimeAuthoritative : true ,
977+ } ) ;
978+
979+ expectMaintenanceCounts ( await runTaskRegistryMaintenance ( ) , { reconciled : 1 } ) ;
980+ expectTaskStatus ( currentTasks , task . taskId , "lost" ) ;
981+ } ) ;
982+
983+ it ( "marks a same-run CLI peer task lost when the parent subagent run is terminal" , async ( ) => {
984+ // Regression for #90444: the issue reports both the parent runtime='subagent'
985+ // row and the child runtime='cli' row for the same run staying stuck. The
986+ // terminal-run fast path must cover both runtimes.
987+ const runId = "run-cli-peer-90444" ;
988+ const subagentTask = makeStaleTask ( {
989+ runtime : "subagent" ,
990+ runId,
991+ childSessionKey : "agent:main:subagent:peer-90444" ,
992+ } ) ;
993+ const cliPeerTask = makeStaleTask ( {
994+ runtime : "cli" ,
995+ sourceId : runId ,
996+ childSessionKey : "agent:main:cli:peer-90444" ,
997+ } ) ;
998+
999+ const { currentTasks } = createTaskRegistryMaintenanceHarness ( {
1000+ tasks : [ subagentTask , cliPeerTask ] ,
1001+ sessionStore : {
1002+ "agent:main:subagent:peer-90444" : {
1003+ sessionId : "sess-sub-peer-90444" ,
1004+ updatedAt : Date . now ( ) ,
1005+ } ,
1006+ "agent:main:cli:peer-90444" : { sessionId : "sess-cli-peer-90444" , updatedAt : Date . now ( ) } ,
1007+ } ,
1008+ terminalSubagentRunEndedAt : { [ runId ] : Date . now ( ) - 5000 } ,
1009+ runtimeAuthoritative : true ,
1010+ } ) ;
1011+
1012+ expectMaintenanceCounts ( await runTaskRegistryMaintenance ( ) , { reconciled : 2 } ) ;
1013+ expectTaskStatus ( currentTasks , subagentTask . taskId , "lost" ) ;
1014+ expectTaskStatus ( currentTasks , cliPeerTask . taskId , "lost" ) ;
1015+ } ) ;
1016+ } ) ;
0 commit comments