mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 00:02:35 +00:00
fix(agent-status): close lifecycle recovery edge cases
This commit is contained in:
@@ -91,13 +91,13 @@ export function bindProviderListeners(session: PtyIpcSession): void {
|
||||
return
|
||||
}
|
||||
if (!isLocalProvider) {
|
||||
clearProviderPtyState(payload.id)
|
||||
ptyOwnership.delete(payload.id)
|
||||
markClaudePtyExited(payload.id)
|
||||
session.runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId, {
|
||||
providerExitObserved: true,
|
||||
...(payload.cause ? { cause: payload.cause } : {})
|
||||
})
|
||||
clearProviderPtyState(payload.id)
|
||||
ptyOwnership.delete(payload.id)
|
||||
markClaudePtyExited(payload.id)
|
||||
}
|
||||
// Why not the whole payload: the exit cause is a main-process fact for the
|
||||
// runtime's records; the renderer's pty:exit contract stays as it was.
|
||||
|
||||
@@ -102,13 +102,13 @@ export function configureLocalPtyProvider(args: {
|
||||
if (!isCurrentPtyExit({ id, incarnationId })) {
|
||||
return
|
||||
}
|
||||
clearProviderPtyState(id)
|
||||
ptyOwnership.delete(id)
|
||||
markClaudePtyExited(id)
|
||||
runtime?.onPtyExit(id, code, incarnationId, {
|
||||
providerExitObserved: true,
|
||||
...(cause ? { cause } : {})
|
||||
})
|
||||
clearProviderPtyState(id)
|
||||
ptyOwnership.delete(id)
|
||||
markClaudePtyExited(id)
|
||||
},
|
||||
onData: (id, data, timestamp, sequenceChars, transformed) =>
|
||||
runtime?.onPtyData(id, data, timestamp, sequenceChars ?? data.length, transformed)
|
||||
|
||||
@@ -2279,6 +2279,7 @@ export class SshRelaySession {
|
||||
private retireExitedPty(payload: SshPtyExitPayload, deliveryHandled = false): void {
|
||||
const relayPtyId = toRelaySshPtyId(this.targetId, payload.id)
|
||||
this.retiredSourceDeliveries.activate(relayPtyId)
|
||||
this.runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId)
|
||||
clearProviderPtyState(payload.id)
|
||||
deletePtyOwnership(payload.id)
|
||||
this.rejectedPtyRecoveryAttempts.delete(payload.id)
|
||||
@@ -2290,7 +2291,6 @@ export class SshRelaySession {
|
||||
if (deliveryHandled) {
|
||||
return
|
||||
}
|
||||
this.runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId)
|
||||
const win = this.getMainWindow()
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('pty:exit', payload)
|
||||
|
||||
@@ -198,6 +198,16 @@ describe('provider turn evidence adapter', () => {
|
||||
true
|
||||
)
|
||||
).toBeNull()
|
||||
expect(
|
||||
providerCurrentTurnInventory(
|
||||
{
|
||||
turnId: 'turn-1',
|
||||
joinedChildren: [{ id: 'duplicate', phase: 'active' }],
|
||||
residentBackground: [{ id: 'duplicate', phase: 'active' }]
|
||||
},
|
||||
true
|
||||
)
|
||||
).toBeNull()
|
||||
expect(
|
||||
providerCurrentTurnInventory(
|
||||
{
|
||||
|
||||
@@ -92,6 +92,10 @@ export function providerCurrentTurnInventory(
|
||||
if (joinedChildren === null || residentBackground === null) {
|
||||
return null
|
||||
}
|
||||
const workIds = new Set([...joinedChildren, ...residentBackground].map((item) => item.workId))
|
||||
if (workIds.size !== joinedChildren.length + residentBackground.length) {
|
||||
return null
|
||||
}
|
||||
const startedAt = optionalTimestamp(record.startedAt)
|
||||
if (startedAt === null) {
|
||||
return null
|
||||
|
||||
@@ -48,6 +48,7 @@ export function recoveryStarted(
|
||||
lastEvidence: eventEvidence(event)
|
||||
})
|
||||
) {
|
||||
state.recoveries = state.recoveries.filter((entry) => entry.custodyId !== event.custodyId)
|
||||
issue(state, {
|
||||
kind: 'capacity-overflow',
|
||||
turnId: event.turnId,
|
||||
|
||||
@@ -148,7 +148,7 @@ function deriveDispatchOutcome(
|
||||
state: AgentTurnLifecycleState,
|
||||
dispatch: AgentTurnDispatchRecord
|
||||
): AgentTurnDispatchRecord['outcome'] {
|
||||
if (dispatch.receipt !== 'received' || dispatch.outcome !== null) {
|
||||
if (dispatch.receipt !== 'received') {
|
||||
return dispatch.outcome
|
||||
}
|
||||
const turn = findTurn(state, dispatch.turnId)
|
||||
@@ -167,6 +167,21 @@ function deriveDispatchOutcome(
|
||||
if (turn.outcome !== 'completed') {
|
||||
return turn.outcome
|
||||
}
|
||||
const joined = state.work.filter(
|
||||
(item) => item.turnId === dispatch.turnId && item.kind === 'joined-child'
|
||||
)
|
||||
if (dispatch.outcome === 'completed') {
|
||||
return joined.some(
|
||||
(item) =>
|
||||
item.phase === 'active' ||
|
||||
item.phase === 'unresolved' ||
|
||||
item.phase === 'abandoned' ||
|
||||
item.outcome === 'failed' ||
|
||||
item.outcome === 'interrupted'
|
||||
)
|
||||
? 'unresolved'
|
||||
: 'completed'
|
||||
}
|
||||
if (
|
||||
state.integrityIssues.some(
|
||||
(entry) => entry.kind === 'capacity-overflow' && entry.turnId === dispatch.turnId
|
||||
@@ -174,9 +189,6 @@ function deriveDispatchOutcome(
|
||||
) {
|
||||
return 'unresolved'
|
||||
}
|
||||
const joined = state.work.filter(
|
||||
(item) => item.turnId === dispatch.turnId && item.kind === 'joined-child'
|
||||
)
|
||||
if (joined.some((item) => item.phase === 'active')) {
|
||||
return null
|
||||
}
|
||||
@@ -192,7 +204,7 @@ function deriveDispatchOutcome(
|
||||
export function reconcileDispatches(state: AgentTurnLifecycleState, observedAt: number): void {
|
||||
for (const dispatch of state.dispatches) {
|
||||
const outcome = deriveDispatchOutcome(state, dispatch)
|
||||
if (outcome !== null && dispatch.outcome === null) {
|
||||
if (outcome !== null && outcome !== dispatch.outcome) {
|
||||
dispatch.outcome = outcome
|
||||
dispatch.settledAt = observedAt
|
||||
}
|
||||
|
||||
@@ -124,6 +124,42 @@ describe('canonical agent turn lifecycle reducer', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('reopens a completed dispatch when a late joined child makes completion uncertain', () => {
|
||||
let current = state()
|
||||
current = apply(current, event({ kind: 'turn-started', turnId: 'turn-late-child' })).state
|
||||
current = apply(
|
||||
current,
|
||||
event({ kind: 'dispatch-associated', dispatchId: 'dispatch-late', turnId: 'turn-late-child' })
|
||||
).state
|
||||
current = apply(
|
||||
current,
|
||||
event({ kind: 'dispatch-received', dispatchId: 'dispatch-late', turnId: 'turn-late-child' })
|
||||
).state
|
||||
current = apply(
|
||||
current,
|
||||
event({
|
||||
kind: 'turn-outcome-observed',
|
||||
turnId: 'turn-late-child',
|
||||
outcome: 'completed',
|
||||
recordKind: 'event'
|
||||
})
|
||||
).state
|
||||
expect(current.dispatches[0]).toMatchObject({ outcome: 'completed' })
|
||||
const lateChild = apply(
|
||||
current,
|
||||
event({
|
||||
kind: 'work-started',
|
||||
turnId: 'turn-late-child',
|
||||
workId: 'child-after-root',
|
||||
workKind: 'joined-child'
|
||||
})
|
||||
)
|
||||
expect(lateChild.state.dispatches[0]).toMatchObject({ outcome: 'unresolved' })
|
||||
expect(lateChild.committedDispatches).toEqual([
|
||||
expect.objectContaining({ dispatchId: 'dispatch-late', outcome: 'unresolved' })
|
||||
])
|
||||
})
|
||||
|
||||
it('allows an attributable terminal record to recover a missed start, but not an ordinary end', () => {
|
||||
const ordinary = apply(
|
||||
state(),
|
||||
@@ -298,6 +334,26 @@ describe('canonical agent turn lifecycle reducer', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not retain recovery custody when turn admission is at capacity', () => {
|
||||
let current = state()
|
||||
for (let index = 0; index < 128; index += 1) {
|
||||
current = apply(current, event({ kind: 'turn-started', turnId: `turn-${index}` })).state
|
||||
}
|
||||
const rejected = apply(
|
||||
current,
|
||||
event({
|
||||
kind: 'turn-recovery-started',
|
||||
turnId: 'turn-overflow',
|
||||
custodyId: 'custody-overflow',
|
||||
deadlineAt: 1000
|
||||
})
|
||||
)
|
||||
expect(rejected.reason).toBe('capacity')
|
||||
expect(rejected.state.recoveries).not.toContainEqual(
|
||||
expect.objectContaining({ custodyId: 'custody-overflow' })
|
||||
)
|
||||
})
|
||||
|
||||
it('deduplicates replay and keeps a stable completion identity', () => {
|
||||
const first = apply(
|
||||
state(),
|
||||
|
||||
Reference in New Issue
Block a user