mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
fix(orchestration): trust a live own-process turn end instead of comparing clocks
Drop programActivitySince and the turn-end clock mapping: replays and SSH host clocks made the comparison unreliable. A Claude lead counts as at rest under a program working report when its hook turn end is a live transition from this pane's host and launch, past the existing input and respawn floors.
This commit is contained in:
@@ -149,8 +149,6 @@ export type RuntimePtyTitleTrackerEntry = {
|
||||
commandCodeDetector: { observe: (data: string) => boolean } | null
|
||||
/** The program's OSC 7501 activity; while set it, not the title glyph, is the PTY's status. */
|
||||
programActivity: AgentStatus | null
|
||||
/** Wall clock of the last programActivity change. */
|
||||
programActivitySince: number | null
|
||||
/** The last observed title, so a program's activity change can re-classify it. */
|
||||
lastTitle: { raw: string; normalized: string } | null
|
||||
}
|
||||
|
||||
@@ -9,9 +9,6 @@ export abstract class OrcaRuntimeWithApplyPtyProgramActivity extends OrcaRuntime
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
if (entry.programActivity !== activity) {
|
||||
entry.programActivitySince = Date.now()
|
||||
}
|
||||
entry.programActivity = activity
|
||||
this.observeClaudeTerminalEvidence(ptyId, { kind: 'activity', status: activity })
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
|
||||
@@ -198,7 +198,6 @@ export abstract class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends Or
|
||||
? this.createTerminalSideEffectCommandCodeDetector(ptyId)
|
||||
: null,
|
||||
programActivity: null,
|
||||
programActivitySince: null,
|
||||
// The persisted title is already normalized, so it stands for both forms.
|
||||
lastTitle: initialTitle !== null ? { raw: initialTitle, normalized: initialTitle } : null
|
||||
}
|
||||
|
||||
@@ -190,7 +190,6 @@ export abstract class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission
|
||||
return null
|
||||
}
|
||||
const { handles, paneKeys, hookRows } = join
|
||||
const tracker = this.ptyTitleTrackersByPtyId.get(ptyId)
|
||||
return readTuiIdleHookTurn({
|
||||
agent,
|
||||
handles,
|
||||
@@ -201,8 +200,7 @@ export abstract class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission
|
||||
launchToken: pty.launchToken,
|
||||
titleObservedAtEpochMs: pty.lastOscTitleEpochMs,
|
||||
hasExplicitIdleTitle: hasExplicitIdleTitle(pty),
|
||||
programWorkingSince:
|
||||
tracker?.programActivity === 'working' ? tracker.programActivitySince : null,
|
||||
programWorking: this.ptyTitleTrackersByPtyId.get(ptyId)?.programActivity === 'working',
|
||||
respawnedAt: this.agentPromptExplicitStatusFloorByPtyId.get(ptyId),
|
||||
lastInputAt: this.terminalRunFacts.readLastInputAt(ptyId),
|
||||
tailIncludes: (text) => retainedTailIncludes(pty.tailBuffer, pty.tailPartialLine, text),
|
||||
|
||||
@@ -178,34 +178,33 @@ describe('readTuiIdleHookTurn', () => {
|
||||
).toEqual({ state: 'permission', blockedReason: null })
|
||||
})
|
||||
|
||||
it("marks a done newer than the program's working report, and joins Agent Teams to claude's row", () => {
|
||||
const hookRow = row({ agentType: 'claude' })
|
||||
const { receivedAt } = hookRow
|
||||
const read = (programWorkingSince: number | null) =>
|
||||
it("marks a live turn end under program work, and joins Agent Teams to claude's row", () => {
|
||||
const transition = (kind: 'transition' | 'snapshot', launchToken = 'launch-1') =>
|
||||
row({
|
||||
agentType: 'claude',
|
||||
launchToken,
|
||||
observation: {
|
||||
origin: 'hook',
|
||||
authorityId: 'local',
|
||||
incarnation: 1,
|
||||
revision: 1,
|
||||
observedAt: 1,
|
||||
kind
|
||||
}
|
||||
})
|
||||
const read = (hookRow: AgentStatusIpcPayload, programWorking = true) =>
|
||||
readTuiIdleHookTurn({
|
||||
...base,
|
||||
agent: 'claude-agent-teams',
|
||||
launchToken: 'launch-1',
|
||||
hookRows: [hookRow],
|
||||
programWorkingSince
|
||||
programWorking
|
||||
})
|
||||
expect(read(receivedAt - 1000)).toEqual({ ...DONE, endedUnderProgramWork: true })
|
||||
// A child's hook restamps the row, but the lead's turn still ended before the program's report.
|
||||
const childChurn = row({
|
||||
agentType: 'claude',
|
||||
state: 'working',
|
||||
mainAgent: { state: 'done', stateStartedAt: receivedAt - 2000 }
|
||||
})
|
||||
expect(
|
||||
readTuiIdleHookTurn({
|
||||
...base,
|
||||
agent: 'claude',
|
||||
hookRows: [childChurn],
|
||||
programWorkingSince: receivedAt - 1000
|
||||
})
|
||||
).toEqual(DONE)
|
||||
// Work the program reported after the turn end, or no working report at all, is not outlasted.
|
||||
expect(read(receivedAt + 1)).toEqual(DONE)
|
||||
expect(read(null)).toEqual(DONE)
|
||||
expect(read(transition('transition'))).toEqual({ ...DONE, endedUnderProgramWork: true })
|
||||
// A replayed done restates an old turn; another launch's done speaks for its predecessor.
|
||||
expect(read(transition('snapshot'))).toEqual(DONE)
|
||||
expect(read(transition('transition', 'launch-0'))).toEqual(DONE)
|
||||
expect(read(transition('transition'), false)).toEqual(DONE)
|
||||
})
|
||||
|
||||
it('takes the newest joined row', () => {
|
||||
|
||||
@@ -14,8 +14,8 @@ export type TuiIdleHookTurn = {
|
||||
taskWakeupPending?: true
|
||||
/** When a fresh `done`'s turn has had time to paint its last frame. */
|
||||
finalFrameAt?: number
|
||||
/** A clear turn end newer than the program's ongoing `working` report, which then covers work
|
||||
* beyond the lead's turn (e.g. live teammates) rather than the turn itself. */
|
||||
/** A live turn end while the program still reports `working`, which then covers work beyond
|
||||
* the lead's turn (e.g. live teammates) rather than the turn itself. */
|
||||
endedUnderProgramWork?: true
|
||||
}
|
||||
|
||||
@@ -30,13 +30,6 @@ function hookReportingAgent(agent: TuiAgent | null | undefined): TuiAgent | null
|
||||
return agent === 'claude-agent-teams' ? 'claude' : agent
|
||||
}
|
||||
|
||||
/** When the lead's own turn ended, on this runtime's clock: child events restamp `receivedAt`, and
|
||||
* a remote host stamps the turn on its own clock. */
|
||||
function leadTurnEndedAt(row: AgentStatusIpcPayload): number {
|
||||
const ended = row.turnEndedAt ?? row.mainAgent?.stateStartedAt ?? row.stateStartedAt
|
||||
return ended + row.receivedAt - (row.evidenceObservedAt ?? row.receivedAt)
|
||||
}
|
||||
|
||||
/** Scans newest-first so the common hit (this turn's plan) returns early. */
|
||||
export function retainedTailIncludes(
|
||||
lines: readonly string[],
|
||||
@@ -86,8 +79,8 @@ export type TuiIdleHookTurnRead = {
|
||||
* the turn that input may have started (or the agent it restarted), whose first hook can still
|
||||
* be in flight. */
|
||||
lastInputAt?: number | null
|
||||
/** When the program's current OSC 7501 `working` report began; null while it reports none. */
|
||||
programWorkingSince?: number | null
|
||||
/** Whether the program currently reports OSC 7501 `working`. */
|
||||
programWorking?: boolean
|
||||
/** Whether the pane's retained output holds `text`; read only while a fresh `done` may precede a menu. */
|
||||
tailIncludes?: (text: string) => boolean
|
||||
/** The existing permission arbiter, given the turn as the pane's explicit status. */
|
||||
@@ -110,6 +103,11 @@ export function readTuiIdleHookTurn(read: TuiIdleHookTurnRead): TuiIdleHookTurn
|
||||
return null
|
||||
}
|
||||
const blockedReason = read.resolveBlockedText(state, row)
|
||||
// Why: an older host or launch's late hook speaks for its predecessor, not this process.
|
||||
const ownHook =
|
||||
row.observation?.origin === 'hook' &&
|
||||
terminalHostConnectionMatches(row.connectionId, read.connectionId ?? null, read.wslDistro) &&
|
||||
(!read.launchToken || row.launchToken === read.launchToken)
|
||||
// Why the hook alone blocks: a question or custom modal paints no dialog text the arbiter knows,
|
||||
// and these hooks report its answer. Input since may have answered it before the hook arrived.
|
||||
const taskWakeupPending =
|
||||
@@ -122,23 +120,23 @@ export function readTuiIdleHookTurn(read: TuiIdleHookTurnRead): TuiIdleHookTurn
|
||||
typeof read.titleObservedAtEpochMs === 'number' &&
|
||||
read.titleObservedAtEpochMs > (row.turnStartedAt ?? row.receivedAt)
|
||||
))) &&
|
||||
row.observation?.origin === 'hook' &&
|
||||
row.providerSession?.key === 'session_id' &&
|
||||
terminalHostConnectionMatches(row.connectionId, read.connectionId ?? null, read.wslDistro) &&
|
||||
(!read.launchToken || row.launchToken === read.launchToken)
|
||||
ownHook &&
|
||||
row.providerSession?.key === 'session_id'
|
||||
const finalFrameAt = row.receivedAt + TURN_END_FINAL_FRAME_MS
|
||||
const finalFramePending =
|
||||
state === 'done' &&
|
||||
hookAuthority(read.agent) === 'turn-end' &&
|
||||
Date.now() < finalFrameAt &&
|
||||
read.tailIncludes?.(CODEX_PROPOSED_PLAN_HEADER) === true
|
||||
// Why a transition: a replay or snapshot restates an old turn end and cannot prove this one ended.
|
||||
const endedUnderProgramWork =
|
||||
state === 'done' &&
|
||||
read.programWorking === true &&
|
||||
ownHook &&
|
||||
row.observation?.kind === 'transition' &&
|
||||
!taskWakeupPending &&
|
||||
!finalFramePending &&
|
||||
blockedReason === null &&
|
||||
typeof read.programWorkingSince === 'number' &&
|
||||
leadTurnEndedAt(row) > Math.max(read.programWorkingSince, read.lastInputAt ?? -1)
|
||||
blockedReason === null
|
||||
return {
|
||||
state,
|
||||
...(taskWakeupPending ? { taskWakeupPending: true as const } : {}),
|
||||
|
||||
Reference in New Issue
Block a user