From 59ab5e741f992a52922fac112e6ec1491f8d7a5e Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sun, 4 Oct 2026 10:25:23 +0800 Subject: [PATCH 1/4] fix(runtime-host): reject manual archive while WorkHub relationships are live Manual archive committed isArchived without consulting two live relationships. An active WorkHub delegation loses its visible target because WorkHub candidate and result views exclude archived Sessions, and a live ordinary linked child was left behind while the retirement checks only covered turns, messages, interactions, goals, runtime resources, derived effects, Agent Graph state, and scheduled tasks; #readFamilySessionIds has never included ordinary linked children. Guard the manual archive path (#setLifecycle with state 'archived') before retirement admission: a batched active-delegation lookup per revision family and a scan for live ordinary linked children of family members now reject the archive with session_busy, naming the delegated member or the parent of the live child. Removal keeps its existing relationship-aware plan, and unarchive and no-op archives are unaffected. The removal preview fixture and the revision UDS retention flow now retire the live subtask before archiving its parent family. Fixes #5956 Generated-by: GLM-5.3-Flash (ZCode) --- .../__tests__/fixtures/workhub-delegation.ts | 97 +++++++++++++++++++ .../session-retirement-coordinator.test.ts | 83 +++++++++++++++- .../session-revision-two-client-uds.test.ts | 21 +++- .../server/session-retirement-coordinator.ts | 38 ++++++++ 4 files changed, 235 insertions(+), 4 deletions(-) create mode 100644 packages/runtime-host/src/__tests__/fixtures/workhub-delegation.ts diff --git a/packages/runtime-host/src/__tests__/fixtures/workhub-delegation.ts b/packages/runtime-host/src/__tests__/fixtures/workhub-delegation.ts new file mode 100644 index 00000000000..d37fe437563 --- /dev/null +++ b/packages/runtime-host/src/__tests__/fixtures/workhub-delegation.ts @@ -0,0 +1,97 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { messageContentDigest, normalizeMessageContent } from '@maka/core/events'; +import { + WORKHUB_COORDINATION_SESSION_ID, + WORKHUB_COORDINATION_SESSION_ROLE, + type WorkHubDelegationAssignedMessage, +} from '@maka/core/session'; +import { createSessionStore } from '@maka/storage/session-store'; + +type DelegationStore = Pick< + ReturnType, + 'createStableSession' | 'assignWorkHubMessage' +>; + +export async function createCoordinationSession( + store: Pick, + root: string, +): Promise { + await store.createStableSession({ + sessionId: WORKHUB_COORDINATION_SESSION_ID, + requestFingerprint: `sha256:${'a'.repeat(64)}`, + input: { + cwd: root, + name: 'WorkHub', + role: WORKHUB_COORDINATION_SESSION_ROLE, + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'explore', + toolProfile: 'workhub-coordination-v1', + }, + }); +} + +export type AssignmentRequest = ReturnType; + +export function assignmentRequest( + actionId: string, + targetSessionId: string, + targetSessionName: string, + targetTurnId: string, +) { + const suffix = createHash('sha256').update(actionId, 'utf8').digest('hex').slice(0, 48); + const content = normalizeMessageContent({ text: 'Continue payment work' }); + const assignment: WorkHubDelegationAssignedMessage = { + type: 'workhub_coordination', + id: `wha_${suffix}`, + turnId: actionId, + ts: 10, + schemaVersion: 1, + kind: 'delegation_assigned', + actionId, + actionFingerprint: `sha256:${'c'.repeat(64)}`, + coordinationTurnId: actionId, + targetSessionId, + targetSessionName, + targetTurnId, + targetMessageId: `whm_${suffix}`, + delegationId: `whd_${suffix}`, + disposition: 'delegate_existing', + userText: content.text, + }; + return { + assignment, + admission: { + sessionId: targetSessionId, + turnId: targetTurnId, + runId: `whr_${suffix}`, + messageId: assignment.targetMessageId, + content, + submittedContentDigest: messageContentDigest(content), + submittedPlacement: 'current_turn' as const, + placement: 'current_turn' as const, + disposition: 'steering' as const, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + admittedAt: 10, + }, + }; +} diff --git a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts index ac9272b7cef..3709cb781d7 100644 --- a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts @@ -48,6 +48,7 @@ import { MemoryExtractionSessionLane } from '../server/memory-extraction-session import { HostSessionRetirementCoordinator } from '../server/session-retirement-coordinator.js'; import { purgeSessionSidecars } from '../server/session-sidecar-purge.js'; import { waitFor as pollFor } from '@maka/core/test-only/async-primitives'; +import { assignmentRequest, createCoordinationSession } from './fixtures/workhub-delegation.js'; const CONNECTION_CONTEXT: ConnectionContext = { hostEpoch: 'retirement-test', @@ -347,6 +348,80 @@ describe('Host Session retirement coordinator', () => { }); }); + test('rejects manual archive while a WorkHub delegation targets the Session family', async () => { + await withHarness(async (harness) => { + await createCoordinationSession(harness.store, harness.workspaceRoot); + const request = assignmentRequest( + 'delegation-archive-block', + harness.revisionId, + 'Revision child', + 'target-turn', + ); + await harness.store.assignWorkHubMessage(request); + assert.deepEqual( + await harness.store.readActiveWorkHubAssignmentsByTarget([harness.revisionId]), + [request.assignment], + ); + + // Archiving any member of the family would hide the delegated target + // from the WorkHub candidate and result views. + const archived = await harness.coordinator.handlers['session.lifecycle.set']( + { sessionId: harness.rootId, state: 'archived' }, + CONNECTION_CONTEXT, + ); + assert.equal(archived.ok, false); + if (archived.ok) return; + assert.equal(archived.error.code, 'session_busy'); + assert.ok(archived.error.message.includes(harness.revisionId)); + assert.match(archived.error.message, /active WorkHub delegation/); + await assertFamilyLifecycle(harness, false); + }); + }); + + test('rejects manual archive while a linked child Session is still live', async () => { + await withHarness(async (harness) => { + const childId = await createClosedSubagent(harness, harness.rootId, 1); + const blocked = await harness.coordinator.handlers['session.lifecycle.set']( + { sessionId: harness.rootId, state: 'archived' }, + CONNECTION_CONTEXT, + ); + assert.equal(blocked.ok, false); + if (blocked.ok) return; + assert.equal(blocked.error.code, 'session_busy'); + assert.ok(blocked.error.message.includes(harness.rootId)); + assert.match(blocked.error.message, /linked child/); + await assertFamilyLifecycle(harness, false); + assert.equal((await harness.store.readHeaderSnapshot(childId)).isArchived, false); + + // The child carries no live relationship of its own, so it archives alone. + const childArchived = await harness.coordinator.handlers['session.lifecycle.set']( + { sessionId: childId, state: 'archived' }, + CONNECTION_CONTEXT, + ); + assert.equal(childArchived.ok, true); + + // With no live child left, the parent archives again. + const parentArchived = await harness.coordinator.handlers['session.lifecycle.set']( + { sessionId: harness.rootId, state: 'archived' }, + CONNECTION_CONTEXT, + ); + assert.equal(parentArchived.ok, true); + await assertFamilyLifecycle(harness, true); + }); + }); + + test('archives a family without WorkHub relationships beside a live coordination Session', async () => { + await withHarness(async (harness) => { + await createCoordinationSession(harness.store, harness.workspaceRoot); + const archived = await harness.coordinator.handlers['session.lifecycle.set']( + { sessionId: harness.revisionId, state: 'archived' }, + CONNECTION_CONTEXT, + ); + assert.equal(archived.ok, true); + await assertFamilyLifecycle(harness, true); + }); + }); + test('archives direct subagent Sessions when their parent family is removed', async () => { await withHarness(async (harness) => { const childSessionIds: string[] = []; @@ -930,8 +1005,10 @@ describe('Host Session retirement coordinator', () => { others.push((await harness.store.create(sessionInput(`Archived ${index}`))).id); } const active = await harness.store.create(sessionInput('Still active')); - // An ordinary subtask is archived on its own, not with its parent. - for (const sessionId of [harness.rootId, worktreeChildId, ...others]) { + // An ordinary subtask is archived on its own, not with its parent — and + // the parent archive refuses while the subtask is still live, so the + // subtask retires first. + for (const sessionId of [worktreeChildId, harness.rootId, ...others]) { const archived = await harness.coordinator.handlers['session.lifecycle.set']( { sessionId, state: 'archived' }, CONNECTION_CONTEXT, @@ -1506,6 +1583,8 @@ async function withHarness( probeSessionRemoval: (sessionId) => store.probeSessionRemoval(sessionId), readCatalogRecord: (sessionId) => store.readCatalogRecord(sessionId), readHeaderRecordSnapshot: (sessionId) => store.readHeaderRecordSnapshot(sessionId), + readActiveWorkHubAssignmentsByTarget: (sessionIds) => + store.readActiveWorkHubAssignmentsByTarget(sessionIds), reconcileOrphanedAgentGraphRetirements: () => store.reconcileOrphanedAgentGraphRetirements(), listPendingSessionRetirementCleanupIds: (sessionId) => diff --git a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts index 29564c00b7e..8e1a7710372 100644 --- a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts @@ -108,6 +108,7 @@ test('two Clients share exact retryable Session branch and revision authority', linkedChildSourceSessionId, metadataLinkedSourceSessionId, ordinaryLinkedChildSessionId, + ordinaryChildSessionId, archivedOwnedSourceSessionId, graphChildSessionId, continuationSourceSessionId, @@ -138,7 +139,7 @@ test('two Clients share exact retryable Session branch and revision authority', host = undefined; host = await startHost(root, capability.rootId); - await verifySecondRestartRetention(root, sourceSessionId); + await verifySecondRestartRetention(root, sourceSessionId, ordinaryChildSessionId); await stopHost(host); host = undefined; @@ -771,7 +772,11 @@ async function verifyRestartRecoveryAndAdmission( } } -async function verifySecondRestartRetention(root: string, sourceSessionId: string): Promise { +async function verifySecondRestartRetention( + root: string, + sourceSessionId: string, + ordinaryChildSessionId: string, +): Promise { const recovered = await connectClient(root); try { assert.deepEqual( @@ -793,6 +798,16 @@ async function verifySecondRestartRetention(root: string, sourceSessionId: strin (await querySession(recovered, GRAPH_REVISION_TARGET_ID)).revisionState, 'committed', ); + // The graph revision family carries linkedChildSource, whose ordinary + // subtask must retire first: a manual archive refuses while a linked + // child is still live. + const archivedOrdinaryChild = requireSessionProjection( + await recovered.request('session.lifecycle.set', { + sessionId: ordinaryChildSessionId, + state: 'archived', + }), + ); + assert.equal(archivedOrdinaryChild.isArchived, true); const archivedGraphRevision = requireSessionProjection( await recovered.request('session.lifecycle.set', { sessionId: GRAPH_REVISION_TARGET_ID, @@ -855,6 +870,7 @@ async function seedSource( linkedChildSourceSessionId: string; metadataLinkedSourceSessionId: string; ordinaryLinkedChildSessionId: string; + ordinaryChildSessionId: string; archivedOwnedSourceSessionId: string; graphChildSessionId: string; continuationSourceSessionId: string; @@ -1782,6 +1798,7 @@ async function seedSource( linkedChildSourceSessionId: linkedChildSource.id, metadataLinkedSourceSessionId: metadataLinkedSource.id, ordinaryLinkedChildSessionId: ordinaryLinkedChild.header.id, + ordinaryChildSessionId: ordinaryChild.header.id, archivedOwnedSourceSessionId: archivedOwnedSource.id, graphChildSessionId: graphChild.header.id, continuationSourceSessionId: continuationSource.id, diff --git a/packages/runtime-host/src/server/session-retirement-coordinator.ts b/packages/runtime-host/src/server/session-retirement-coordinator.ts index 9c23a463040..0a9bf0bfcb0 100644 --- a/packages/runtime-host/src/server/session-retirement-coordinator.ts +++ b/packages/runtime-host/src/server/session-retirement-coordinator.ts @@ -67,12 +67,16 @@ import type { MemoryExtractionSessionLane } from './memory-extraction-session-la const FAMILY_STABILIZATION_ATTEMPTS = 4; +/** The store rejects a WorkHub target batch above its own bound; page below it. */ +const WORKHUB_ASSIGNMENT_TARGET_PAGE = 256; + type RetirementStores = Pick< ExecutionSessionWriter, | 'listHeaders' | 'probeSessionRemoval' | 'readCatalogRecord' | 'readHeaderRecordSnapshot' + | 'readActiveWorkHubAssignmentsByTarget' | 'reconcileOrphanedAgentGraphRetirements' | 'listPendingSessionRetirementCleanupIds' | 'completeSessionRetirementCleanup' @@ -289,6 +293,7 @@ export class HostSessionRetirementCoordinator { let handles: RetirementHandles | undefined; let committed = false; try { + await this.#assertNoLiveRelationships(family); handles = await this.#prepareRetirement(family, 'archive'); await this.#finalizeWorkspacePatches(family.sessionIds); await this.#disposeBackends(family.sessionIds); @@ -720,6 +725,39 @@ export class HostSessionRetirementCoordinator { ); } + /** + * A manual archive must not hide a Session that a WorkHub delegation or a + * linked child still points at: WorkHub candidate and result views exclude + * archived Sessions, so either relationship would lose its visible target. + * This guards the manual archive path only — a removal retires or archives + * the whole relationship set under its own confirm. + */ + async #assertNoLiveRelationships(family: StableFamily): Promise { + const familyIds = new Set(family.sessionIds); + for ( + let offset = 0; + offset < family.sessionIds.length; + offset += WORKHUB_ASSIGNMENT_TARGET_PAGE + ) { + const page = family.sessionIds.slice(offset, offset + WORKHUB_ASSIGNMENT_TARGET_PAGE); + const [assignment] = await this.#stores.readActiveWorkHubAssignmentsByTarget(page); + if (assignment) { + throw new SessionRetirementBusyError( + `Session ${assignment.targetSessionId} has an active WorkHub delegation`, + ); + } + } + for (const header of await this.#stores.listHeaders()) { + const parent = header.subagentParent; + if (parent === undefined || parent.graph !== undefined) continue; + if (!familyIds.has(parent.parentSessionId)) continue; + if (header.conversationCopy?.state === 'preparing' || header.isArchived) continue; + throw new SessionRetirementBusyError( + `Session ${parent.parentSessionId} has a live linked child Session`, + ); + } + } + async #prepareRetirement( family: StableFamily, kind: 'archive' | 'remove', From 984cfffc444c1c1c5c3ac583ceb844fbc482974c Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sun, 4 Oct 2026 12:30:09 +0800 Subject: [PATCH 2/4] fix(runtime-host): treat retired WorkHub delegations and finished subtasks as archivable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Astro-Han review of PR #5957 narrowed this guard: "live" was defined as the Coordination ledger still holding the delegation row, but that row stays until a delegation is superseded or stopped — finishing its Turn retires the delegation without touching the ledger, so a Session that ever received a delegation could never be archived again. The archive guard now reuses the WorkHub delegation-retirement read (the same one coordination actions run): pending, an active owned root, or a recovering execution still blocks; cancelled, shared, and terminal executions allow the archive. A returnResults delegation whose result has not yet been delivered into the Coordination Session — no root admission receipt — still blocks, because archiving would hide the result's only visible target. A linked child now blocks only while it carries in-flight work (an active or reserved root Turn, queued or in-flight messages, a pending interaction); a finished subtask no longer pins its parent out of the archive. Removal keeps its existing relationship-aware plan, and the shared retirement read is hoisted so both consumers agree on one definition. Regressions cover a completed delegation and a closed subtask archiving successfully; reverting the guard to the ledger/any-child form turns both red again. Fixes #5956 Generated-by: GLM-5.3-Flash (ZCode) --- .../session-retirement-coordinator.test.ts | 93 ++++++++++++++++++- .../src/server/execution-composition.ts | 87 +++++++++++------ .../server/session-retirement-coordinator.ts | 51 +++++++++- .../src/server/workhub-result-runtime.ts | 4 +- 4 files changed, 200 insertions(+), 35 deletions(-) diff --git a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts index 3709cb781d7..4acf8bb55f2 100644 --- a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts @@ -378,9 +378,70 @@ describe('Host Session retirement coordinator', () => { }); }); + test('archives a family whose WorkHub delegation has finished', async () => { + await withHarness(async (harness) => { + await createCoordinationSession(harness.store, harness.workspaceRoot); + const request = assignmentRequest( + 'delegation-archive-finished', + harness.revisionId, + 'Revision child', + 'target-turn', + ); + await harness.store.assignWorkHubMessage(request); + // The Coordination ledger keeps the assignment row until the delegation + // is superseded or stopped; finishing its Turn retires the delegation + // without touching that row. + harness.workHubRetirement.set(request.assignment.delegationId, 'retired'); + + const archived = await harness.coordinator.handlers['session.lifecycle.set']( + { sessionId: harness.rootId, state: 'archived' }, + CONNECTION_CONTEXT, + ); + assert.equal(archived.ok, true); + await assertFamilyLifecycle(harness, true); + }); + }); + + test('rejects manual archive while a retired delegation still owes WorkHub its result', async () => { + await withHarness(async (harness) => { + await createCoordinationSession(harness.store, harness.workspaceRoot); + const request = assignmentRequest( + 'delegation-result-owed', + harness.revisionId, + 'Revision child', + 'target-turn', + ); + await harness.store.assignWorkHubMessage(request); + harness.workHubRetirement.set(request.assignment.delegationId, 'retired'); + harness.workHubUndeliveredResults.add(request.assignment.delegationId); + + const blocked = await harness.coordinator.handlers['session.lifecycle.set']( + { sessionId: harness.rootId, state: 'archived' }, + CONNECTION_CONTEXT, + ); + assert.equal(blocked.ok, false); + if (blocked.ok) return; + assert.equal(blocked.error.code, 'session_busy'); + assert.match(blocked.error.message, /undelivered WorkHub result/); + await assertFamilyLifecycle(harness, false); + + // Once the Host delivers the result, the archive goes through. + harness.workHubUndeliveredResults.delete(request.assignment.delegationId); + const archived = await harness.coordinator.handlers['session.lifecycle.set']( + { sessionId: harness.rootId, state: 'archived' }, + CONNECTION_CONTEXT, + ); + assert.equal(archived.ok, true); + await assertFamilyLifecycle(harness, true); + }); + }); + test('rejects manual archive while a linked child Session is still live', async () => { await withHarness(async (harness) => { const childId = await createClosedSubagent(harness, harness.rootId, 1); + // In-flight work is what keeps the relationship live: a reserved root + // Turn on the child, not the mere existence of the link. + harness.blockers.root.add(childId); const blocked = await harness.coordinator.handlers['session.lifecycle.set']( { sessionId: harness.rootId, state: 'archived' }, CONNECTION_CONTEXT, @@ -393,7 +454,9 @@ describe('Host Session retirement coordinator', () => { await assertFamilyLifecycle(harness, false); assert.equal((await harness.store.readHeaderSnapshot(childId)).isArchived, false); - // The child carries no live relationship of its own, so it archives alone. + // The child carries no live relationship of its own, so once its work + // ends it archives alone. + harness.blockers.root.delete(childId); const childArchived = await harness.coordinator.handlers['session.lifecycle.set']( { sessionId: childId, state: 'archived' }, CONNECTION_CONTEXT, @@ -410,6 +473,20 @@ describe('Host Session retirement coordinator', () => { }); }); + test('archives a family beside a finished linked subtask', async () => { + await withHarness(async (harness) => { + await createClosedSubagent(harness, harness.rootId, 1); + // A subtask whose work has ended does not keep its parent out of the + // archive; unlike a removal, the parent's archive leaves it in place. + const archived = await harness.coordinator.handlers['session.lifecycle.set']( + { sessionId: harness.rootId, state: 'archived' }, + CONNECTION_CONTEXT, + ); + assert.equal(archived.ok, true); + await assertFamilyLifecycle(harness, true); + }); + }); + test('archives a family without WorkHub relationships beside a live coordination Session', async () => { await withHarness(async (harness) => { await createCoordinationSession(harness.store, harness.workspaceRoot); @@ -1545,6 +1622,8 @@ async function withHarness( const quiescentGraphs = new Set(); const memoryExtractionLane = new MemoryExtractionSessionLane(); const admission = new SessionAdmissionGate(); + const workHubRetirement = new Map(); + const workHubUndeliveredResults = new Set(); const harness: RetirementHarness = { workspaceRoot: root, store, @@ -1557,6 +1636,8 @@ async function withHarness( quiescentGraphs, admission, memoryExtractionLane, + workHubRetirement, + workHubUndeliveredResults, failRemoveCommit: false, failRemovalPublication: false, failArtifactCleanup: false, @@ -1616,6 +1697,12 @@ async function withHarness( hasLiveSessionState: (sessionId) => blockers.message.has(sessionId), retireSessions: (sessionIds) => actions.retiredMessages.push(...sessionIds), }, + workHub: { + readDelegationRetirement: async (assignment) => + workHubRetirement.get(assignment.delegationId) ?? 'not_retired', + hasUndeliveredResult: async (assignment) => + workHubUndeliveredResults.has(assignment.delegationId), + }, interactions: { hasPendingSession: async (sessionId) => blockers.interaction.has(sessionId), }, @@ -1766,6 +1853,10 @@ interface RetirementHarness { readonly quiescentGraphs: Set; readonly admission: SessionAdmissionGate; readonly memoryExtractionLane: MemoryExtractionSessionLane; + /** Delegation-retirement verdict per delegationId; absent reads as live. */ + readonly workHubRetirement: Map; + /** Delegations whose returnResults payload has not reached WorkHub. */ + readonly workHubUndeliveredResults: Set; coordinator: HostSessionRetirementCoordinator; failRemoveCommit: boolean; failMeasurement: boolean; diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index d751ed9ae37..96ddd464264 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -40,6 +40,7 @@ import { } from '@maka/core/runtime-invocation'; import { type SessionHeader, + type WorkHubDelegationAssignedMessage, WORKHUB_COORDINATION_SESSION_ID, WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION, } from '@maka/core/session'; @@ -237,7 +238,8 @@ import { resolveSandboxBoundaryRootSession } from './sandbox-boundary-graph-wake import { HostRuntimePolicyCoordinator } from './runtime-policy-coordinator.js'; import { startHostModelMetadataRefresh } from './model-metadata-refresh.js'; import { HostRuntimeResourceCoordinator } from './runtime-resource-coordinator.js'; -import { SessionAdmissionGate } from './session-admission-gate.js'; +import { SessionAdmissionGate, type SessionAdmissionLease } from './session-admission-gate.js'; +import { workHubResultOrigin } from './workhub-result-coordinator.js'; import { HostSessionCatalogCoordinator } from './session-catalog-coordinator.js'; import { HostWorkspaceResolver } from './workspace-resolver.js'; import { HostSessionRetirementCoordinator } from './session-retirement-coordinator.js'; @@ -2299,6 +2301,43 @@ export async function createExecutionRuntimeHostComposition( return outcome.result.kind === 'committed' ? 'committed' : 'revision_conflict'; }, }); + /** + * One delegation-retirement read for both consumers: the coordination + * coordinator's own actions and the archive guard's relationship check. + * A finished Turn retires the delegation long before anything removes its + * ledger row, so "live" must come from the execution, not the ledger. + */ + const readWorkHubDelegationRetirement = async ( + assignment: WorkHubDelegationAssignedMessage, + admission?: SessionAdmissionLease, + ): Promise<'retired' | 'not_retired' | 'recovering'> => { + const disposition = admission + ? await messages.readMessageExecutionDispositionAdmitted( + assignment.targetSessionId, + assignment.targetMessageId, + admission, + ) + : await messages.readMessageExecutionDisposition( + assignment.targetSessionId, + assignment.targetMessageId, + ); + if (disposition.kind === 'recovering') return 'recovering'; + if (disposition.kind === 'pending') return 'not_retired'; + if (disposition.kind === 'cancelled' || disposition.kind === 'shared_turn') { + return 'retired'; + } + const identity = { + sessionId: assignment.targetSessionId, + turnId: disposition.turnId, + runId: disposition.runId, + }; + const latest = await coordinator.readLatestRootTurnLineage(identity); + if (isActiveWorkHubRoot(coordinator, latest)) return 'not_retired'; + // The same restart window as `stopOwnedWorkHubRoot`: an unregistered + // root is not evidence that its work ended. + const snapshot = await coordinator.read(latest); + return isHostedExecutionTerminal(snapshot) ? 'retired' : 'recovering'; + }; workHubCoordination = new HostWorkHubCoordinationCoordinator({ routingModel: dependencies.workHubRoutingModel ?? createJevRoutingModel({ stores: runtimePolicyStores }), @@ -2316,34 +2355,7 @@ export async function createExecutionRuntimeHostComposition( continuity: continuityCoordinator, executions: coordinator, sessionActions: { - readDelegationRetirement: async (assignment, admission) => { - const disposition = admission - ? await messages.readMessageExecutionDispositionAdmitted( - assignment.targetSessionId, - assignment.targetMessageId, - admission, - ) - : await messages.readMessageExecutionDisposition( - assignment.targetSessionId, - assignment.targetMessageId, - ); - if (disposition.kind === 'recovering') return 'recovering'; - if (disposition.kind === 'pending') return 'not_retired'; - if (disposition.kind === 'cancelled' || disposition.kind === 'shared_turn') { - return 'retired'; - } - const identity = { - sessionId: assignment.targetSessionId, - turnId: disposition.turnId, - runId: disposition.runId, - }; - const latest = await coordinator.readLatestRootTurnLineage(identity); - if (isActiveWorkHubRoot(coordinator, latest)) return 'not_retired'; - // The same restart window as `stopOwnedWorkHubRoot`: an unregistered - // root is not evidence that its work ended. - const snapshot = await coordinator.read(latest); - return isHostedExecutionTerminal(snapshot) ? 'retired' : 'recovering'; - }, + readDelegationRetirement: readWorkHubDelegationRetirement, // Resolve and resume only the execution lineage owned by this // delegation. A Session-wide latest-failure query could otherwise // continue unrelated work started directly in the same Session. @@ -2785,6 +2797,23 @@ export async function createExecutionRuntimeHostComposition( root: coordinator, messages, interactions, + workHub: { + readDelegationRetirement: (assignment, admission) => + readWorkHubDelegationRetirement(assignment, admission), + hasUndeliveredResult: async (assignment, admission) => { + if (!workHubResults || !assignment.returnResults) return false; + // Same observation delivery is keyed on; the transcript content is + // not needed to identify the result event. + const observed = await workHubResults.inspect(assignment, admission, false); + if (!observed) return false; + // Root admission is the delivery receipt (see startWorkHubResult). + const receipt = await stores.agentRunStore.readRootTurnAdmission( + WORKHUB_COORDINATION_SESSION_ID, + workHubResultOrigin(assignment, observed).eventId, + ); + return !receipt; + }, + }, goals: requireGoal(goal), scheduledTasks, resources: runtimeResources, diff --git a/packages/runtime-host/src/server/session-retirement-coordinator.ts b/packages/runtime-host/src/server/session-retirement-coordinator.ts index 0a9bf0bfcb0..88d03e3ac61 100644 --- a/packages/runtime-host/src/server/session-retirement-coordinator.ts +++ b/packages/runtime-host/src/server/session-retirement-coordinator.ts @@ -21,6 +21,7 @@ import { isWorkHubCoordinationSessionTarget, sessionRevisionFamilyId, type SessionHeader, + type WorkHubDelegationAssignedMessage, } from '@maka/core/session'; import type { SubagentWorkspaceBinding, @@ -119,6 +120,21 @@ export interface HostSessionRetirementCoordinatorOptions { readonly root: RetirementRoot; readonly messages: RetirementMessages; readonly interactions: RetirementInteractions; + /** + * WorkHub relationship reads shared with the coordination coordinator. The + * archive guard needs the delegation's execution state, not just its ledger + * row, and both must agree on what "still live" means. + */ + readonly workHub: { + readDelegationRetirement( + assignment: WorkHubDelegationAssignedMessage, + admission: SessionAdmissionLease, + ): Promise<'retired' | 'not_retired' | 'recovering'>; + hasUndeliveredResult( + assignment: WorkHubDelegationAssignedMessage, + admission: SessionAdmissionLease, + ): Promise; + }; readonly goals: RetirementGoals; readonly scheduledTasks: { beginSessionRetirement( @@ -199,6 +215,7 @@ export class HostSessionRetirementCoordinator { readonly #root: RetirementRoot; readonly #messages: RetirementMessages; readonly #interactions: RetirementInteractions; + readonly #workHub: HostSessionRetirementCoordinatorOptions['workHub']; readonly #goals: RetirementGoals; readonly #scheduledTasks: HostSessionRetirementCoordinatorOptions['scheduledTasks']; readonly #resources: RetirementResources; @@ -229,6 +246,7 @@ export class HostSessionRetirementCoordinator { this.#root = options.root; this.#messages = options.messages; this.#interactions = options.interactions; + this.#workHub = options.workHub; this.#goals = options.goals; this.#scheduledTasks = options.scheduledTasks; this.#resources = options.resources; @@ -729,6 +747,10 @@ export class HostSessionRetirementCoordinator { * A manual archive must not hide a Session that a WorkHub delegation or a * linked child still points at: WorkHub candidate and result views exclude * archived Sessions, so either relationship would lose its visible target. + * "Live" means the relationship is still in flight — the delegation's + * execution has not retired (or its result has not been delivered), or the + * child still carries active work. A finished delegation and a finished + * subtask hold nothing visible hostage, so they no longer block the archive. * This guards the manual archive path only — a removal retires or archives * the whole relationship set under its own confirm. */ @@ -741,23 +763,44 @@ export class HostSessionRetirementCoordinator { ) { const page = family.sessionIds.slice(offset, offset + WORKHUB_ASSIGNMENT_TARGET_PAGE); const [assignment] = await this.#stores.readActiveWorkHubAssignmentsByTarget(page); - if (assignment) { + if (!assignment) continue; + // The Coordination ledger keeps a delegation row until it is superseded + // or stopped, so the row alone says nothing about the work: read the + // delegation's execution state the same way WorkHub actions do. + const retirement = await this.#workHub.readDelegationRetirement(assignment, family.admission); + if (retirement !== 'retired') { throw new SessionRetirementBusyError( `Session ${assignment.targetSessionId} has an active WorkHub delegation`, ); } + if (await this.#workHub.hasUndeliveredResult(assignment, family.admission)) { + throw new SessionRetirementBusyError( + `Session ${assignment.targetSessionId} has an undelivered WorkHub result`, + ); + } } for (const header of await this.#stores.listHeaders()) { const parent = header.subagentParent; if (parent === undefined || parent.graph !== undefined) continue; if (!familyIds.has(parent.parentSessionId)) continue; if (header.conversationCopy?.state === 'preparing' || header.isArchived) continue; - throw new SessionRetirementBusyError( - `Session ${parent.parentSessionId} has a live linked child Session`, - ); + if (await this.#hasLiveChildWork(header.id)) { + throw new SessionRetirementBusyError( + `Session ${parent.parentSessionId} has a live linked child Session`, + ); + } } } + /** The in-flight signals that make a linked child worth keeping visible. */ + async #hasLiveChildWork(sessionId: string): Promise { + return ( + this.#root.readRootState(sessionId).kind !== 'idle' || + this.#messages.hasLiveSessionState(sessionId) || + (await this.#interactions.hasPendingSession(sessionId)) + ); + } + async #prepareRetirement( family: StableFamily, kind: 'archive' | 'remove', diff --git a/packages/runtime-host/src/server/workhub-result-runtime.ts b/packages/runtime-host/src/server/workhub-result-runtime.ts index 6ae79a75aec..b87f3d70518 100644 --- a/packages/runtime-host/src/server/workhub-result-runtime.ts +++ b/packages/runtime-host/src/server/workhub-result-runtime.ts @@ -340,5 +340,7 @@ export function createWorkHubResultRuntime(options: { return { status: outcome.result.status, targetSessionId: assignment.targetSessionId }; }, }; - return { coordinator, tool, notify }; + // Exposed for the archive guard: the receipt read needs the same observation + // (and therefore the same event identity) delivery itself is keyed on. + return { coordinator, tool, notify, inspect }; } From 93016855be94553e4921dd75cb5c2b8d491c6595 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sun, 4 Oct 2026 12:30:13 +0800 Subject: [PATCH 3/4] fix(desktop): surface archive refusals and retry bulk archive once after the sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The archive toast swallowed the Host's refusal behind a generic "Could not archive task": the session_busy reason crossed Electron IPC as a bare message with its error class stripped. The sessions:archive handler now re-keys the archive-guard refusals a user can act on — a live WorkHub delegation, an undelivered WorkHub result, a live linked child — into stable session_archive_refused tokens, and the rail maps each to localized copy naming the action that unblocks the archive (stop or replace the delegation / retry in a moment / archive the subtasks first) for zh-CN, zh-TW and en. The bulk sweep archived in selection order, so a parent picked before its own subtask failed on the first pass and stayed failed. One retry after the sweep settles ordering inside the selection without teaching the rail about subtask links; a refusal that was never about ordering fails once more and is reported exactly as before. Focused tests cover the refusal mapping and the retry; the renderer architecture ledger passes --strict-base against 7c90bac2d. Generated-by: GLM-5.3-Flash (ZCode) --- .../session-navigation-archive-sweep.test.ts | 166 ++++++++++++++++++ .../runtime-host-session-catalog-ipc-main.ts | 26 ++- .../controller/session-row-actions.ts | 76 ++++++-- .../src/renderer/locales/shell-copy.ts | 15 ++ 4 files changed, 264 insertions(+), 19 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/session-navigation-archive-sweep.test.ts diff --git a/apps/desktop/src/main/__tests__/session-navigation-archive-sweep.test.ts b/apps/desktop/src/main/__tests__/session-navigation-archive-sweep.test.ts new file mode 100644 index 00000000000..e3ebef1053e --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-navigation-archive-sweep.test.ts @@ -0,0 +1,166 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { + createSessionNavigationRowActions, + type SessionNavigationSessionService, +} from '../../renderer/features/session-navigation/testing.js'; +import { getShellCopy } from '../../renderer/locales/shell-copy.js'; + +const copy = getShellCopy('en').sessionRowActions; + +/** What Electron IPC does to a thrown error: keep the message, drop the class. */ +function ipcError(token: string): Error { + return new Error(`Error invoking remote method 'sessions:archive': Error: ${token}`); +} + +type Harness = { + /** Each `archive` call, in order. */ + archives: string[]; + /** Each error toast, in order. */ + errors: Array<{ title: string; description?: string }>; + /** Each success toast, in order. */ + successes: Array<{ title: string; description?: string }>; + /** Archive refusals per session id, popped one per call; absent archives fine. */ + refusals: Map; +}; + +function harness(): Harness { + return { archives: [], errors: [], successes: [], refusals: new Map() }; +} + +function installService(h: Harness): SessionNavigationSessionService { + return { + list: async () => [], + setFlagged: async () => undefined, + archive: async (sessionId: string) => { + h.archives.push(sessionId); + const refusal = h.refusals.get(sessionId)?.shift(); + if (refusal) throw refusal; + }, + unarchive: async () => undefined, + rename: async () => undefined, + remove: async () => ({ disposition: 'removed', archivedSubtaskCount: 0 }), + previewRemoval: async () => 0, + previewRemovals: async () => ({ + archivableSubtaskCount: 0, + removedSubtaskCount: 0, + worktreeCount: 0, + }), + moveToProject: async () => ({ ok: true }), + }; +} + +function createActions(h: Harness) { + return createSessionNavigationRowActions({ + uiLocale: 'en', + acquireAutomaticQueryBlock: () => ({ release: () => undefined }), + clearSessionRendererState: () => undefined, + pendingSessionRowActionsRef: { current: new Set() }, + refreshSessions: async () => [], + service: installService(h), + sessionsRef: { current: [] }, + toastApi: { + success: (title: string, description?: string) => { + h.successes.push({ title, description }); + }, + error: (title: string, description?: string) => { + h.errors.push({ title, description }); + }, + confirm: async () => true, + }, + }); +} + +describe('archiveSession refusals', () => { + it('names the delegation action when the Host refuses for a live WorkHub delegation', async () => { + const h = harness(); + h.refusals.set('s1', [ + ipcError('session_archive_refused: workhub_delegation'), + ]); + const actions = createActions(h); + + await actions.archiveSession('s1'); + + assert.deepEqual(h.errors, [ + { title: copy.archiveFailedTitle, description: copy.archiveRefusedDelegation }, + ]); + }); + + it('names the subtask action when the Host refuses for a live linked child', async () => { + const h = harness(); + h.refusals.set('s1', [ipcError('session_archive_refused: linked_child')]); + const actions = createActions(h); + + await actions.archiveSession('s1'); + + assert.deepEqual(h.errors, [ + { title: copy.archiveFailedTitle, description: copy.archiveRefusedSubtasks }, + ]); + }); + + it('keeps the generic line for a refusal it cannot explain', async () => { + const h = harness(); + h.refusals.set('s1', [ipcError('persistence_failed: disk on fire')]); + const actions = createActions(h); + + await actions.archiveSession('s1'); + + assert.equal(h.errors.length, 1); + assert.equal(h.errors[0]!.title, copy.archiveFailedTitle); + assert.notEqual(h.errors[0]!.description, copy.archiveRefusedDelegation); + assert.notEqual(h.errors[0]!.description, copy.archiveRefusedSubtasks); + }); +}); + +describe('archiveSelected sweep', () => { + it('retries a parent refused ahead of its own subtask and reports success', async () => { + const h = harness(); + // The selection lists the parent first; the Host refuses it while the + // subtask later in the sweep is still live. + h.refusals.set('parent', [ipcError('session_archive_refused: linked_child')]); + const actions = createActions(h); + + await actions.archiveSelected(['parent', 'child']); + + assert.deepEqual(h.archives, ['parent', 'child', 'parent']); + assert.deepEqual(h.errors, []); + assert.deepEqual(h.successes, [ + { title: copy.bulkArchivedTitle(2), description: copy.bulkArchiveDescription }, + ]); + }); + + it('retries a genuine refusal once and still reports it as failed', async () => { + const h = harness(); + h.refusals.set('s1', [ + ipcError('session_archive_refused: workhub_delegation'), + ipcError('session_archive_refused: workhub_delegation'), + ]); + const actions = createActions(h); + + await actions.archiveSelected(['s1']); + + assert.deepEqual(h.archives, ['s1', 's1']); + assert.deepEqual(h.errors, [ + { title: copy.bulkArchiveFailedTitle, description: copy.archiveRefusedDelegation }, + ]); + }); +}); diff --git a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts index 4ce9a437912..7bc8efccd8c 100644 --- a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts @@ -159,7 +159,11 @@ export function registerRuntimeHostSessionCatalogIpc( ipcMain.handle('sessions:archive', async (_event, sessionId: string, options?: unknown) => { requestsRevisionFamily(options); const ids = await actionIds(sessionId, { revisionFamily: true }); - await deps.client.setSessionLifecycle(sessionId, 'archived'); + try { + await deps.client.setSessionLifecycle(sessionId, 'archived'); + } catch (error) { + throw asArchiveRefusal(error); + } await finishSessionRetirement(deps, ids, 'archived'); }); ipcMain.handle('sessions:unarchive', async (_event, sessionId: string, options?: unknown) => { @@ -384,6 +388,26 @@ async function updateConfiguration( return { ok: true, session: toDesktopHostSessionSummary(session) }; } +/** + * Electron IPC strips the error class off a `RuntimeHostOperationError`, so a + * refusal the rail can explain has to travel as a stable token in the message + * text. These are the archive guard's `session_busy` refusals a user can act + * on; any other failure keeps the Host's own message. + */ +const ARCHIVE_REFUSAL_TOKENS: ReadonlyArray = [ + ['has an active WorkHub delegation', 'workhub_delegation'], + ['has an undelivered WorkHub result', 'workhub_result'], + ['has a live linked child Session', 'linked_child'], +]; + +function asArchiveRefusal(error: unknown): unknown { + if (!(error instanceof RuntimeHostOperationError) || error.code !== 'session_busy') return error; + for (const [needle, reason] of ARCHIVE_REFUSAL_TOKENS) { + if (error.message.includes(needle)) return new Error(`session_archive_refused: ${reason}`); + } + return error; +} + const EXPECTED_UPDATE_FAILURES = [ 'session_busy', 'operation_conflict', diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts b/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts index 028270b040b..8e108b3f101 100644 --- a/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts +++ b/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts @@ -165,6 +165,7 @@ export function createSessionNavigationRowActions(deps: { actionId: 'flag' | 'archive' | 'rename' | 'delete' | 'move', errorTitle: string, action: () => Promise, + describeError?: (error: unknown) => string | undefined, ): Promise { const sessionPrefix = `${sessionId}:`; if (Array.from(pendingSessionRowActionsRef.current).some((key) => key.startsWith(sessionPrefix))) return; @@ -175,7 +176,7 @@ export function createSessionNavigationRowActions(deps: { } catch (error) { toastApi.error( errorTitle, - localizedShellErrorMessage(error, copy.actionFallback, uiLocale), + describeError?.(error) ?? localizedShellErrorMessage(error, copy.actionFallback, uiLocale), undefined, { sessionId }, ); @@ -184,6 +185,23 @@ export function createSessionNavigationRowActions(deps: { } } + /** + * Archive refusals worth explaining arrive re-keyed by the main process as + * `session_archive_refused: ` — Electron IPC strips the error class, + * so the guard's session_busy message cannot carry it any other way. Each + * token names the action that unblocks the archive; anything else keeps the + * generic failure line. + */ + function archiveRefusalDescription(error: unknown): string | undefined { + const reason = (error instanceof Error ? error.message : String(error)).match( + /session_archive_refused: (\S+)/, + )?.[1]; + if (reason === 'workhub_delegation') return copy.archiveRefusedDelegation; + if (reason === 'workhub_result') return copy.archiveRefusedResult; + if (reason === 'linked_child') return copy.archiveRefusedSubtasks; + return undefined; + } + async function flagSession(sessionId: string, flagged: boolean) { return runSessionRowAction(sessionId, 'flag', flagged ? copy.flagFailedTitle : copy.unflagFailedTitle, async () => { await service.setFlagged(sessionId, flagged, { revisionFamily: true }); @@ -192,13 +210,19 @@ export function createSessionNavigationRowActions(deps: { } async function archiveSession(sessionId: string) { - return runSessionRowAction(sessionId, 'archive', copy.archiveFailedTitle, async () => { - await withAutomaticQueryBlock(sessionId, async (familyIds) => { - await service.archive(sessionId, { revisionFamily: true }); - for (const id of familyIds) clearSessionRendererState(id); - await refreshSessions(); - }); - }); + return runSessionRowAction( + sessionId, + 'archive', + copy.archiveFailedTitle, + async () => { + await withAutomaticQueryBlock(sessionId, async (familyIds) => { + await service.archive(sessionId, { revisionFamily: true }); + for (const id of familyIds) clearSessionRendererState(id); + await refreshSessions(); + }); + }, + archiveRefusalDescription, + ); } async function unarchiveSession(sessionId: string) { @@ -495,6 +519,12 @@ export function createSessionNavigationRowActions(deps: { * the delete sweep's shape, which would carry a `restored` field that can * never be anything but empty. * + * A selection can hold a parent picked before its own subtask; the Host + * refuses that first call while the subtask is still live. One retry after + * the sweep settles the order inside the selection without teaching the rail + * about subtask links — and a refusal that was never about ordering fails + * once more, exactly as before. + * * Like the sweep, it raises no toast per task: one action is one message, and * a run of them is what a sweep exists to avoid. */ @@ -503,18 +533,17 @@ export function createSessionNavigationRowActions(deps: { new Set(sessionIds.flatMap((sessionId) => revisionFamilySessionIds(sessionsRef.current, sessionId))), ); return withAutomaticQueryBlockOn(familyIds, async () => { - const failed: string[] = []; - let firstFailure: SessionArchiveOutcome['firstFailure']; let archived = 0; - for (const sessionId of sessionIds) { + const failures = new Map(); + const attempt = async (sessionId: string): Promise => { const key = `${sessionId}:archive`; if ( Array.from(pendingSessionRowActionsRef.current).some((pending) => pending.startsWith(`${sessionId}:`), ) ) { - failed.push(sessionId); - continue; + failures.set(sessionId, {}); + return; } pendingSessionRowActionsRef.current.add(key); try { @@ -522,17 +551,27 @@ export function createSessionNavigationRowActions(deps: { await service.archive(sessionId, { revisionFamily: true }); for (const id of rowFamilyIds) clearSessionRendererState(id); archived += 1; + failures.delete(sessionId); } catch (error) { - failed.push(sessionId); - firstFailure ??= { error, sessionId }; + failures.set(sessionId, { error }); } finally { pendingSessionRowActionsRef.current.delete(key); } - } + }; + for (const sessionId of sessionIds) await attempt(sessionId); + for (const sessionId of [...failures.keys()]) await attempt(sessionId); + const failed = [...failures.keys()]; + const firstFailedId = failed.find((sessionId) => failures.get(sessionId)?.error !== undefined); // Once, after the whole sweep. Refreshing per task would re-render the rail // under the user's cursor for every id in the selection. await refreshSessions(); - return { archived, failed, firstFailure }; + return { + archived, + failed, + firstFailure: firstFailedId + ? { error: failures.get(firstFailedId)!.error, sessionId: firstFailedId } + : undefined, + }; }); } @@ -561,7 +600,8 @@ export function createSessionNavigationRowActions(deps: { toastApi.error( copy.bulkArchiveFailedTitle, outcome.firstFailure - ? localizedShellErrorMessage(outcome.firstFailure.error, copy.actionFallback, uiLocale) + ? archiveRefusalDescription(outcome.firstFailure.error) ?? + localizedShellErrorMessage(outcome.firstFailure.error, copy.actionFallback, uiLocale) : copy.bulkFailedBody(outcome.failed.length), undefined, outcome.firstFailure ? { sessionId: outcome.firstFailure.sessionId } : undefined, diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index bbecc4bc4d2..e57a70f4e94 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -268,6 +268,10 @@ type ShellCopy = { 'session_busy' | 'operation_conflict' | 'operation_unavailable' | 'not_found', string >; + /** Why the Host refused an archive, keyed by the guard's refusal token. */ + archiveRefusedDelegation: string; + archiveRefusedResult: string; + archiveRefusedSubtasks: string; currentConversation: string; deleteTitle(name: string): string; deleteDescription: string; @@ -886,6 +890,9 @@ const SHELL_COPY_BY_LOCALE = { operation_unavailable: '当前无法移动这个任务。', not_found: '找不到该项目或任务。', }, + archiveRefusedDelegation: 'WorkHub 仍在向该任务委派工作。先停止或更换委派,再归档。', + archiveRefusedResult: '委派结果还没有送达 WorkHub。请稍后重试归档。', + archiveRefusedSubtasks: '该任务还有进行中的子任务。请先归档子任务。', currentConversation: '当前任务', deleteTitle: (name: string) => `删除 "${name}"`, deleteDescription: '任务和全部消息会从磁盘上永久移除。该操作不可撤销。', @@ -1410,6 +1417,9 @@ const SHELL_COPY_BY_LOCALE = { operation_unavailable: '目前無法移動這個任務。', not_found: '找不到該專案或任務。', }, + archiveRefusedDelegation: 'WorkHub 仍在向該任務委派工作。先停止或更換委派,再歸檔。', + archiveRefusedResult: '委派結果還沒有送達 WorkHub。請稍後重試歸檔。', + archiveRefusedSubtasks: '該任務還有進行中的子任務。請先歸檔子任務。', currentConversation: '目前任務', deleteTitle: (name: string) => `刪除 "${name}"`, deleteDescription: '任務和全部訊息會從磁碟上永久移除。該操作不可撤銷。', @@ -1936,6 +1946,11 @@ const SHELL_COPY_BY_LOCALE = { operation_unavailable: 'This task cannot be moved right now.', not_found: 'That project or task could not be found.', }, + archiveRefusedDelegation: + 'WorkHub still delegates work to this task. Stop or replace the delegation, then archive it.', + archiveRefusedResult: + "The delegated result has not reached WorkHub yet. Try archiving again in a moment.", + archiveRefusedSubtasks: 'This task has subtasks still in flight. Archive them first.', currentConversation: 'Current task', deleteTitle: (name: string) => `Delete "${name}"`, deleteDescription: From 32241cbae86051284a38aaa45a63df3b2812c944 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sun, 4 Oct 2026 16:48:40 +0800 Subject: [PATCH 4/4] fix(runtime-host): check every active WorkHub assignment, not just the newest Second-round review of #5957 found that #assertNoLiveRelationships read only the first row of each readActiveWorkHubAssignmentsByTarget page. The store returns every active assignment for the page's targets, newest first, so a target carrying two delegations was judged by its newest row alone: an older delegation still in flight, or retired but still owing WorkHub its result, was skipped and the archive was let through. Iterate every assignment in the page and apply the same retirement and undelivered-result checks to each; any hit refuses the archive. Regression tests from the reviewer's scratch scenarios: with two assignments on the same target, an older not-retired delegation next to a retired newest one now refuses with the active-delegation error, an older retired delegation with an undelivered result refuses with the undelivered-result error, and all-retired with nothing owed still archives. Red against the old first-row read, green after the loop, red again when reverted. Also rewords two archive-refusal lines the same review flagged, in en, zh-CN and zh-TW: the undelivered-result line no longer suggests retrying in a moment (delivery may be indefinitely pending, so it now points at opening WorkHub or stopping the delegation), and the linked-child line no longer tells the user to archive subtasks the Host itself refuses to archive (it now says to wait for them to finish or stop them). Generated-by: GLM-5.3-Flash (ZCode) --- .../src/renderer/locales/shell-copy.ts | 13 +-- .../session-retirement-coordinator.test.ts | 81 +++++++++++++++++++ .../server/session-retirement-coordinator.ts | 35 ++++---- 3 files changed, 109 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index e57a70f4e94..b8c89790e52 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -891,8 +891,8 @@ const SHELL_COPY_BY_LOCALE = { not_found: '找不到该项目或任务。', }, archiveRefusedDelegation: 'WorkHub 仍在向该任务委派工作。先停止或更换委派,再归档。', - archiveRefusedResult: '委派结果还没有送达 WorkHub。请稍后重试归档。', - archiveRefusedSubtasks: '该任务还有进行中的子任务。请先归档子任务。', + archiveRefusedResult: '委派结果还没有送达 WorkHub。请打开 WorkHub 收取结果,或停止该委派。', + archiveRefusedSubtasks: '该任务还有进行中的子任务。请等子任务完成或停止它们,再归档该任务。', currentConversation: '当前任务', deleteTitle: (name: string) => `删除 "${name}"`, deleteDescription: '任务和全部消息会从磁盘上永久移除。该操作不可撤销。', @@ -1418,8 +1418,8 @@ const SHELL_COPY_BY_LOCALE = { not_found: '找不到該專案或任務。', }, archiveRefusedDelegation: 'WorkHub 仍在向該任務委派工作。先停止或更換委派,再歸檔。', - archiveRefusedResult: '委派結果還沒有送達 WorkHub。請稍後重試歸檔。', - archiveRefusedSubtasks: '該任務還有進行中的子任務。請先歸檔子任務。', + archiveRefusedResult: '委派結果還沒有送達 WorkHub。請開啟 WorkHub 收取結果,或停止該委派。', + archiveRefusedSubtasks: '該任務還有進行中的子任務。請等子任務完成或停止它們,再歸檔該任務。', currentConversation: '目前任務', deleteTitle: (name: string) => `刪除 "${name}"`, deleteDescription: '任務和全部訊息會從磁碟上永久移除。該操作不可撤銷。', @@ -1949,8 +1949,9 @@ const SHELL_COPY_BY_LOCALE = { archiveRefusedDelegation: 'WorkHub still delegates work to this task. Stop or replace the delegation, then archive it.', archiveRefusedResult: - "The delegated result has not reached WorkHub yet. Try archiving again in a moment.", - archiveRefusedSubtasks: 'This task has subtasks still in flight. Archive them first.', + "WorkHub has not received this task's result yet. Open WorkHub so it can collect it, or stop the delegation.", + archiveRefusedSubtasks: + 'This task has subtasks still in flight. Wait for them to finish or stop them, then archive this task.', currentConversation: 'Current task', deleteTitle: (name: string) => `Delete "${name}"`, deleteDescription: diff --git a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts index 4acf8bb55f2..13f9e3ee4dc 100644 --- a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts @@ -436,6 +436,87 @@ describe('Host Session retirement coordinator', () => { }); }); + test('rejects manual archive while an older WorkHub delegation of the same target is still live', async () => { + await withHarness(async (harness) => { + await createCoordinationSession(harness.store, harness.workspaceRoot); + // Two delegations to the same target coexist in the Coordination ledger + // until one supersedes or stops the other; finishing the newest does not + // retire the older one. + const older = assignmentRequest( + 'delegation-archive-older-live', + harness.revisionId, + 'Revision child', + 'target-turn-older', + ); + await harness.store.assignWorkHubMessage(older); + const newer = assignmentRequest( + 'delegation-archive-newer-retired', + harness.revisionId, + 'Revision child', + 'target-turn-newer', + ); + await harness.store.assignWorkHubMessage(newer); + assert.deepEqual( + await harness.store.readActiveWorkHubAssignmentsByTarget([harness.revisionId]), + [newer.assignment, older.assignment], + ); + harness.workHubRetirement.set(newer.assignment.delegationId, 'retired'); + + const archived = await harness.coordinator.handlers['session.lifecycle.set']( + { sessionId: harness.rootId, state: 'archived' }, + CONNECTION_CONTEXT, + ); + assert.equal(archived.ok, false); + if (archived.ok) return; + assert.equal(archived.error.code, 'session_busy'); + assert.match(archived.error.message, /active WorkHub delegation/); + await assertFamilyLifecycle(harness, false); + }); + }); + + test('rejects manual archive while an older retired delegation still owes WorkHub its result', async () => { + await withHarness(async (harness) => { + await createCoordinationSession(harness.store, harness.workspaceRoot); + const older = assignmentRequest( + 'delegation-archive-older-result', + harness.revisionId, + 'Revision child', + 'target-turn-older', + ); + await harness.store.assignWorkHubMessage(older); + const newer = assignmentRequest( + 'delegation-archive-newer-result', + harness.revisionId, + 'Revision child', + 'target-turn-newer', + ); + await harness.store.assignWorkHubMessage(newer); + harness.workHubRetirement.set(newer.assignment.delegationId, 'retired'); + harness.workHubRetirement.set(older.assignment.delegationId, 'retired'); + harness.workHubUndeliveredResults.add(older.assignment.delegationId); + + const blocked = await harness.coordinator.handlers['session.lifecycle.set']( + { sessionId: harness.rootId, state: 'archived' }, + CONNECTION_CONTEXT, + ); + assert.equal(blocked.ok, false); + if (blocked.ok) return; + assert.equal(blocked.error.code, 'session_busy'); + assert.match(blocked.error.message, /undelivered WorkHub result/); + await assertFamilyLifecycle(harness, false); + + // With every delegation retired and no result owed, two assignments no + // more block the archive than one did. + harness.workHubUndeliveredResults.delete(older.assignment.delegationId); + const archived = await harness.coordinator.handlers['session.lifecycle.set']( + { sessionId: harness.rootId, state: 'archived' }, + CONNECTION_CONTEXT, + ); + assert.equal(archived.ok, true); + await assertFamilyLifecycle(harness, true); + }); + }); + test('rejects manual archive while a linked child Session is still live', async () => { await withHarness(async (harness) => { const childId = await createClosedSubagent(harness, harness.rootId, 1); diff --git a/packages/runtime-host/src/server/session-retirement-coordinator.ts b/packages/runtime-host/src/server/session-retirement-coordinator.ts index 88d03e3ac61..8031b178d84 100644 --- a/packages/runtime-host/src/server/session-retirement-coordinator.ts +++ b/packages/runtime-host/src/server/session-retirement-coordinator.ts @@ -762,21 +762,28 @@ export class HostSessionRetirementCoordinator { offset += WORKHUB_ASSIGNMENT_TARGET_PAGE ) { const page = family.sessionIds.slice(offset, offset + WORKHUB_ASSIGNMENT_TARGET_PAGE); - const [assignment] = await this.#stores.readActiveWorkHubAssignmentsByTarget(page); - if (!assignment) continue; - // The Coordination ledger keeps a delegation row until it is superseded - // or stopped, so the row alone says nothing about the work: read the - // delegation's execution state the same way WorkHub actions do. - const retirement = await this.#workHub.readDelegationRetirement(assignment, family.admission); - if (retirement !== 'retired') { - throw new SessionRetirementBusyError( - `Session ${assignment.targetSessionId} has an active WorkHub delegation`, - ); - } - if (await this.#workHub.hasUndeliveredResult(assignment, family.admission)) { - throw new SessionRetirementBusyError( - `Session ${assignment.targetSessionId} has an undelivered WorkHub result`, + // The ledger keeps every live delegation of a target, newest first, so a + // finished newest delegation must not hide an older one that is still in + // flight or still owes WorkHub its result. + const assignments = await this.#stores.readActiveWorkHubAssignmentsByTarget(page); + for (const assignment of assignments) { + // The Coordination ledger keeps a delegation row until it is superseded + // or stopped, so the row alone says nothing about the work: read the + // delegation's execution state the same way WorkHub actions do. + const retirement = await this.#workHub.readDelegationRetirement( + assignment, + family.admission, ); + if (retirement !== 'retired') { + throw new SessionRetirementBusyError( + `Session ${assignment.targetSessionId} has an active WorkHub delegation`, + ); + } + if (await this.#workHub.hasUndeliveredResult(assignment, family.admission)) { + throw new SessionRetirementBusyError( + `Session ${assignment.targetSessionId} has an undelivered WorkHub result`, + ); + } } } for (const header of await this.#stores.listHeaders()) {