fix: unify company runtime recovery lifecycle

This commit is contained in:
LZH-YS1998
2026-07-14 14:35:43 +08:00
parent 5e02364eb4
commit b8202bbe9e
56 changed files with 8753 additions and 3542 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<title>OpenOPC Pixel Office</title>
<script type="module" crossorigin src="./assets/index-02tfsorH.js"></script>
<script type="module" crossorigin src="./assets/index-Cson66Y4.js"></script>
<link rel="modulepreload" crossorigin href="./assets/phaser-DFK5Ua9d.js">
<link rel="stylesheet" crossorigin href="./assets/index-BCWwLlJm.css">
<link rel="stylesheet" crossorigin href="./assets/index-DEvLDWDw.css">
</head>
<body>
<div id="root"></div>
+7 -23
View File
@@ -476,7 +476,6 @@ export default function App() {
const [globalCompanyProfile, setGlobalCompanyProfile] = useState<'corporate' | 'custom'>('corporate')
const [globalTaskPreferredAgent, setGlobalTaskPreferredAgent] = useState<TaskPreferredAgent>('native')
const [orgInfoData, setOrgInfoData] = useState<OrgInfoPayload | null>(null)
const [recoveryStatus, setRecoveryStatus] = useState<any>(null)
const [commsState, setCommsState] = useState<import('./lib/wsClient').CommsStatePayload | null>(null)
const [commsMessage, setCommsMessage] = useState<import('./lib/wsClient').CommsMessagePayload | null>(null)
const [talentTemplates, setTalentTemplates] = useState<TalentTemplate[]>([])
@@ -1239,7 +1238,6 @@ export default function App() {
runtimeControlState: String(payload.runtime_control_state ?? payload.runtimeControlState ?? 'idle') as any,
canStop: Boolean(payload.can_stop ?? payload.canStop),
canResume: Boolean(payload.can_resume ?? payload.canResume),
resumeParentTaskId: String(payload.resume_parent_task_id ?? payload.resumeParentTaskId ?? ''),
resumeParentSessionId: String(payload.resume_parent_session_id ?? payload.resumeParentSessionId ?? ''),
pendingRuntimeCheckpointId: String(payload.pending_runtime_checkpoint_id ?? payload.pendingRuntimeCheckpointId ?? ''),
stopIntentId: String(payload.stop_intent_id ?? payload.stopIntentId ?? ''),
@@ -1749,10 +1747,6 @@ export default function App() {
clientRef.current?.collabSync(getActiveProjectId(), undefined, projectViewGenerationRef.current)
}
},
onRecoveryStatus: (payload) => {
if (!payloadMatchesActiveProject(payload as unknown as Record<string, unknown>, false)) return
setRecoveryStatus(payload)
},
onCommsState: (payload) => {
if (!payloadMatchesActiveProject(payload as unknown as Record<string, unknown>, false)) return
setCommsState(payload)
@@ -1761,13 +1755,6 @@ export default function App() {
if (!payloadMatchesActiveProject(payload as unknown as Record<string, unknown>, true)) return
setCommsMessage(payload)
},
onRecoveryResult: (payload) => {
if (!payloadMatchesActiveProject(payload as unknown as Record<string, unknown>, false)) return
if (payload?.status === 'completed' || payload?.status === 'cancelled') {
// Trigger a re-scan
clientRef.current?.recoveryAction(getActiveProjectId(), 'scan')
}
},
onTalentList: (payload) => {
setTalentTemplates(payload.templates ?? [])
if (payload.talent_dir) setDefaultTalentDir(payload.talent_dir)
@@ -2242,18 +2229,13 @@ export default function App() {
) => {
const session = sessionStore.sessions.find(s => s.taskId === taskId)
const parentSessionId = session?.resumeParentSessionId ?? session?.parentSessionId ?? session?.sessionId
const parentTaskId = session?.resumeParentTaskId
?? (parentSessionId ? sessionStore.sessions.find(s => s.sessionId === parentSessionId && !s.parentSessionId)?.taskId : undefined)
?? taskId
for (const candidate of sessionStore.sessions) {
if (
candidate.taskId === taskId
|| candidate.taskId === parentTaskId
|| (!!parentSessionId && (candidate.parentSessionId === parentSessionId || candidate.sessionId === parentSessionId))
) {
sessionStore.updateSession(candidate.taskId, {
...patch,
resumeParentTaskId: parentTaskId,
resumeParentSessionId: parentSessionId,
})
}
@@ -2278,7 +2260,7 @@ export default function App() {
clientRef.current?.sessionStop(getActiveProjectId(), taskId)
}, [sessionStore.sessions, markRuntimeControlForTask, getActiveProjectId])
const handleSessionResume = useCallback((taskId: string) => {
const handleSessionResume = useCallback((taskId: string, runtimeSessionId?: string, checkpointId?: string) => {
const session = sessionStore.sessions.find(s => s.taskId === taskId)
const isCompanyRuntime = session?.execMode === 'company'
|| session?.execMode === 'org'
@@ -2293,7 +2275,12 @@ export default function App() {
canResume: false,
})
}
clientRef.current?.sessionResume(getActiveProjectId(), taskId)
clientRef.current?.sessionResume(
getActiveProjectId(),
taskId,
runtimeSessionId ?? session?.resumeParentSessionId ?? session?.parentSessionId ?? session?.sessionId,
checkpointId ?? session?.pendingRuntimeCheckpointId,
)
}, [sessionStore.sessions, markRuntimeControlForTask, getActiveProjectId])
const handleGlobalModeChange = useCallback((mode: 'task' | 'company' | 'org' | 'custom', profile?: string, orgId?: string) => {
@@ -2426,9 +2413,6 @@ export default function App() {
activeSavedOrg={activeSavedOrg}
onSavedOrgsList={handleSavedOrgsList}
onSavedOrgLoad={handleSavedOrgLoad}
recoveryStatus={recoveryStatus}
onRecoveryResume={(id) => clientRef.current?.recoveryAction(getActiveProjectId(), 'resume', id)}
onRecoveryCancel={(id) => clientRef.current?.recoveryAction(getActiveProjectId(), 'cancel', id)}
commsState={commsState}
commsMessage={commsMessage}
onCommsRefresh={(opts) => {
@@ -676,7 +676,6 @@ export function mapBackendSession(raw: any): Session {
runtimeControlState: raw.runtime_control_state ?? raw.runtimeControlState,
canStop: raw.can_stop ?? raw.canStop,
canResume: raw.can_resume ?? raw.canResume,
resumeParentTaskId: raw.resume_parent_task_id ?? raw.resumeParentTaskId,
resumeParentSessionId: raw.resume_parent_session_id ?? raw.resumeParentSessionId,
pendingRuntimeCheckpointId: raw.pending_runtime_checkpoint_id ?? raw.pendingRuntimeCheckpointId,
stopIntentId: raw.stop_intent_id ?? raw.stopIntentId,
@@ -387,7 +387,6 @@ export function getConversationSessionView(
runtimeControlState: runtimeSource.runtimeControlState ?? normalizedActiveSession.runtimeControlState,
canStop: runtimeSource.canStop ?? normalizedActiveSession.canStop,
canResume: runtimeSource.canResume ?? normalizedActiveSession.canResume,
resumeParentTaskId: runtimeSource.resumeParentTaskId ?? normalizedActiveSession.resumeParentTaskId,
resumeParentSessionId: runtimeSource.resumeParentSessionId ?? normalizedActiveSession.resumeParentSessionId,
pendingRuntimeCheckpointId: runtimeSource.pendingRuntimeCheckpointId ?? normalizedActiveSession.pendingRuntimeCheckpointId,
stopIntentId: runtimeSource.stopIntentId ?? normalizedActiveSession.stopIntentId,
@@ -447,7 +446,6 @@ export function getConversationHeaderSession(
runtimeControlState: runtimeSource.runtimeControlState ?? normalizedActiveSession.runtimeControlState,
canStop: runtimeSource.canStop ?? normalizedActiveSession.canStop,
canResume: runtimeSource.canResume ?? normalizedActiveSession.canResume,
resumeParentTaskId: runtimeSource.resumeParentTaskId ?? normalizedActiveSession.resumeParentTaskId,
resumeParentSessionId: runtimeSource.resumeParentSessionId ?? normalizedActiveSession.resumeParentSessionId,
pendingRuntimeCheckpointId: runtimeSource.pendingRuntimeCheckpointId ?? normalizedActiveSession.pendingRuntimeCheckpointId,
stopIntentId: runtimeSource.stopIntentId ?? normalizedActiveSession.stopIntentId,
@@ -30,6 +30,17 @@ const flushPromises = async () => {
const client = new VisualSocketClient('ws://unit.test', {})
// Company Continue keeps the selected UI channel task separate from the
// durable runtime identity used by the checkpoint handoff.
client.sessionResume('project-a', 'ui-task', 'runtime-session', 'checkpoint-1')
const resumeEnvelope = JSON.parse(
(client as unknown as TestSocketClient).pendingQueue.pop() ?? '{}',
) as Record<string, unknown>
assert.equal(resumeEnvelope.type, 'session_resume')
assert.equal(resumeEnvelope.task_id, 'ui-task')
assert.equal(resumeEnvelope.runtime_session_id, 'runtime-session')
assert.equal(resumeEnvelope.checkpoint_id, 'checkpoint-1')
// A summary and a full request for the same task are distinct correlations.
// Neither Promise may settle merely because the request was queued locally.
const summaryPromise = client.sessionDetail('project-a', 'task-1', { detailLevel: 'summary' })
@@ -41,8 +41,6 @@ interface SocketHandlers {
onProjectSwitched?: (payload: { project_id: string; switch_seq?: string }) => void
onProjectDeleted?: (payload: { project_id: string }) => void
onOrgInfo?: (payload: OrgInfoPayload) => void
onRecoveryStatus?: (payload: any) => void
onRecoveryResult?: (payload: any) => void
onTalentList?: (payload: TalentListPayload) => void
onTalentScanLocal?: (payload: { templates: Array<{ template_id: string; name: string; description: string; category: string; domains: string[]; tags: string[] }> }) => void
onEmployeeDetail?: (payload: EmployeeDetailPayload) => void
@@ -169,7 +167,6 @@ const PROJECT_SCOPED_MESSAGE_TYPES = new Set([
'session_update_title',
'secretary_send',
'project_index',
'recovery_action',
'comms_state',
'comms_read_message',
])
@@ -420,9 +417,22 @@ export class VisualSocketClient {
this.send({ type: 'session_stop', project_id: pid, task_id: taskId })
}
sessionResume(projectId: string, taskId: string, content?: string): void {
sessionResume(
projectId: string,
taskId: string,
runtimeSessionId?: string,
checkpointId?: string,
content?: string,
): void {
const pid = this.requireProjectId(projectId, 'session_resume')
this.send({ type: 'session_resume', project_id: pid, task_id: taskId, content })
this.send({
type: 'session_resume',
project_id: pid,
task_id: taskId,
runtime_session_id: runtimeSessionId,
checkpoint_id: checkpointId,
content,
})
}
sessionComplete(projectId: string, taskId: string): void {
@@ -646,11 +656,6 @@ export class VisualSocketClient {
this.send({ type: 'org_saved_delete', name })
}
recoveryAction(projectId: string, action: 'resume' | 'cancel' | 'scan', parentTaskId?: string): void {
const pid = this.requireProjectId(projectId, 'recovery_action')
this.send({ type: 'recovery_action', project_id: pid, action, parent_task_id: parentTaskId })
}
commsState(projectId: string, opts?: { task_id?: string; session_id?: string }): void {
const pid = this.requireProjectId(projectId, 'comms_state')
this.send({ type: 'comms_state', project_id: pid, ...(opts || {}) })
@@ -801,12 +806,6 @@ export class VisualSocketClient {
if (projectId) this.commsState(projectId)
} catch { /* ignore */ }
break
case 'recovery_status':
this.handlers.onRecoveryStatus?.(parsed.payload)
break
case 'recovery_result':
this.handlers.onRecoveryResult?.(parsed.payload)
break
case 'talent_list':
this.handlers.onTalentList?.(parsed.payload)
break
@@ -244,7 +244,6 @@ function sessionReducer(state: Session[], action: SessionAction): Session[] {
runtimeControlState: guardedRuntimeControl.runtimeControlState ?? existing.runtimeControlState,
canStop: guardedRuntimeControl.canStop ?? existing.canStop,
canResume: guardedRuntimeControl.canResume ?? existing.canResume,
resumeParentTaskId: incoming.resumeParentTaskId ?? existing.resumeParentTaskId,
resumeParentSessionId: incoming.resumeParentSessionId ?? existing.resumeParentSessionId,
pendingRuntimeCheckpointId: guardedRuntimeControl.pendingRuntimeCheckpointId ?? existing.pendingRuntimeCheckpointId,
stopIntentId: guardedRuntimeControl.stopIntentId ?? existing.stopIntentId,
@@ -337,7 +336,6 @@ function sessionReducer(state: Session[], action: SessionAction): Session[] {
runtimeControlState: guarded.runtimeControlState ?? s.runtimeControlState,
canStop: guarded.canStop ?? s.canStop,
canResume: guarded.canResume ?? s.canResume,
resumeParentTaskId: guarded.resumeParentTaskId ?? s.resumeParentTaskId,
resumeParentSessionId: guarded.resumeParentSessionId ?? s.resumeParentSessionId,
pendingRuntimeCheckpointId: guarded.pendingRuntimeCheckpointId ?? s.pendingRuntimeCheckpointId,
stopIntentId: guarded.stopIntentId ?? s.stopIntentId,
@@ -316,10 +316,9 @@ export interface Session {
originChannel?: string
originTaskId?: string
runtimeControlState?: 'running' | 'suspending' | 'suspended' | 'resuming' | 'idle'
canStop?: boolean
canResume?: boolean
resumeParentTaskId?: string
resumeParentSessionId?: string
canStop?: boolean
canResume?: boolean
resumeParentSessionId?: string
pendingRuntimeCheckpointId?: string
stopIntentId?: string
// Handoff context from upstream work item (Company Mode)
@@ -100,8 +100,6 @@ export type SocketEnvelope =
| { type: 'work_item_batch_updated'; payload: { run_id?: string; work_items: RuntimeWorkItemInfo[]; frontier?: RuntimeFrontierSummary } }
| { type: 'project_recovery_updated'; payload: Record<string, unknown> }
| { type: 'project_revision_created'; payload: { run_id?: string; revision_links: SessionLinkInfo[] } }
| { type: 'recovery_status'; payload: Record<string, unknown> }
| { type: 'recovery_result'; payload: Record<string, unknown> }
| { type: 'talent_list'; payload: TalentListPayload }
| { type: 'talent_scan_local'; payload: { templates: Array<{ template_id: string; name: string; description: string; category: string; domains: string[]; tags: string[] }> } }
| { type: 'employee_detail'; payload: EmployeeDetailPayload }
@@ -77,7 +77,6 @@ interface ContextPanelProps {
onCommsRefresh?: () => void
onCommsReadMessage?: (path: string) => void
orgInfoData?: OrgInfoPayload | null
recoveryStatus?: Record<string, unknown> | null
canShowTeamTab?: boolean
onTeamStopRun?: () => void
@@ -497,7 +496,6 @@ export function ContextPanel({
onCommsRefresh,
onCommsReadMessage,
orgInfoData,
recoveryStatus,
canShowTeamTab = false,
onTeamStopRun,
onTitleChange,
@@ -1306,7 +1304,6 @@ export function ContextPanel({
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
<ProjectCockpit
orgInfoData={orgInfoData ?? null}
recoveryStatus={recoveryStatus ?? null}
commsState={commsState ?? null}
onStopRun={onTeamStopRun}
embedded
@@ -48,7 +48,6 @@ interface TeamCardInfo {
interface ProjectCockpitProps {
orgInfoData?: OrgInfoPayload | null
recoveryStatus?: Record<string, unknown> | null
commsState?: CommsStatePayload | null
onStopRun?: () => void
embedded?: boolean
@@ -56,7 +55,6 @@ interface ProjectCockpitProps {
export function ProjectCockpit({
orgInfoData,
recoveryStatus,
commsState,
onStopRun,
embedded = false,
@@ -78,7 +76,6 @@ export function ProjectCockpit({
count + asRecordList(asRecord(digest.manager_digest).notification_backlog).length
), 0)
const unreadCount = actionableCount + protocolCount + notificationCount
const interrupted = Array.isArray(recoveryStatus?.interrupted) ? recoveryStatus.interrupted.length : 0
const communicationItems = [
{ label: 'Actionable', value: actionableCount },
@@ -198,7 +195,7 @@ export function ProjectCockpit({
<span>Seats {runtimeView.runtimeSeats.length}</span>
<span>Approvals {pendingDecisionCount}</span>
<span>Unread {unreadCount}</span>
<span>Recovery {interrupted > 0 ? `${interrupted} interrupted` : summarizeText(asRecord(projectRun?.recovery_pointer).status, 'clean')}</span>
<span>Run state {summarizeText(asRecord(projectRun?.recovery_pointer).status, 'clean')}</span>
</div>
</div>
@@ -1,116 +0,0 @@
import { useState } from 'react'
export interface RecoverableWorkItem {
work_item_projection_id: string
title: string
task_id: string
status: string
interrupted: boolean
previous_status: string
}
export interface InterruptedWorkItemRuntime {
parent_session_id: string
parent_task_id: string
project_id: string
title: string
profile: string
interrupted_at: string
work_items: RecoverableWorkItem[]
}
export interface RecoveryStatusPayload {
interrupted: InterruptedWorkItemRuntime[]
active_recoveries: string[]
scanned_at: number
}
interface WorkItemRecoveryPanelProps {
data: RecoveryStatusPayload
onResume: (parentTaskId: string) => void
onCancel: (parentTaskId: string) => void
}
const STATUS_ICON: Record<string, string> = {
done: '\u2713',
failed: '\u2717',
pending: '\u25CB',
blocked: '\u25A0',
cancelled: '\u2014',
running: '\u25B6',
}
const STATUS_COLOR: Record<string, string> = {
done: 'var(--green, #27ae60)',
failed: 'var(--red, #e74c3c)',
pending: 'var(--text-secondary, #888)',
blocked: 'var(--yellow, #f39c12)',
cancelled: 'var(--text-dim, #555)',
running: 'var(--accent, #3498db)',
}
export function WorkItemRecoveryPanel({ data, onResume, onCancel }: WorkItemRecoveryPanelProps) {
const [dismissed, setDismissed] = useState<Set<string>>(new Set())
if (!data.interrupted.length && !data.active_recoveries.length) return null
const visible = data.interrupted.filter(w => !dismissed.has(w.parent_task_id))
if (!visible.length && !data.active_recoveries.length) return null
return (
<div className="wfr-panel">
{visible.map(wf => {
const isRecovering = data.active_recoveries.includes(wf.parent_task_id)
const doneCount = wf.work_items.filter(item => item.status === 'done').length
const failedCount = wf.work_items.filter(item => item.interrupted || item.status === 'failed').length
return (
<div key={wf.parent_task_id} className="wfr-card">
<div className="wfr-header">
<span className="wfr-icon">&#x26A0;</span>
<div className="wfr-header-text">
<span className="wfr-title">Interrupted: {wf.title}</span>
<span className="wfr-subtitle">
{doneCount}/{wf.work_items.length} work items done, {failedCount} interrupted
{wf.profile && <> &middot; {wf.profile}</>}
</span>
</div>
</div>
<div className="wfr-work-items">
{wf.work_items.map(item => (
<div key={item.work_item_projection_id} className={`wfr-work-item wfr-work-item--${item.status}`}>
<span className="wfr-work-item-icon" style={{ color: STATUS_COLOR[item.status] || STATUS_COLOR.pending }}>
{STATUS_ICON[item.status] || STATUS_ICON.pending}
</span>
<span className="wfr-work-item-title">{item.title}</span>
{item.interrupted && <span className="wfr-work-item-badge">interrupted</span>}
</div>
))}
</div>
<div className="wfr-actions">
{isRecovering ? (
<span className="wfr-recovering">
<span className="spinner-inline" /> Recovering...
</span>
) : (
<>
<button className="wfr-btn wfr-btn--resume" onClick={() => onResume(wf.parent_task_id)}>
Resume
</button>
<button className="wfr-btn wfr-btn--cancel" onClick={() => onCancel(wf.parent_task_id)}>
Cancel
</button>
<button className="wfr-btn wfr-btn--dismiss" onClick={() => setDismissed(prev => new Set(prev).add(wf.parent_task_id))}>
Dismiss
</button>
</>
)}
</div>
</div>
)
})}
</div>
)
}
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { AgentInfo, OrgInfoPayload, SavedOrgSummary } from '../types/visual'
import { WorkItemRecoveryPanel } from './WorkItemRecoveryPanel'
import type { ChatMessage, CheckpointReplyMetadata, OutgoingAttachmentPayload } from '../types/chat'
import type { KanbanTask, Session, TaskPreferredAgent } from '../types/kanban'
import type { BoardStoreState } from '../kanban/BoardStore'
@@ -253,7 +252,7 @@ interface WorkspacePageProps {
*/
onContinueInNewChat?: (mode: 'task' | 'company' | 'org' | 'custom', companyProfile?: 'corporate' | 'custom', orgId?: string) => void
onSessionStop?: (taskId: string) => void
onSessionResume?: (taskId: string) => void
onSessionResume?: (taskId: string, runtimeSessionId?: string, checkpointId?: string) => void
onSessionComplete?: (taskId: string) => void
onLoadSessionDetail?: (
taskId: string,
@@ -263,9 +262,6 @@ interface WorkspacePageProps {
onCollabSync?: () => void
orgInfoData?: OrgInfoPayload | null
onNavigateToOrg?: () => void
recoveryStatus?: any
onRecoveryResume?: (parentTaskId: string) => void
onRecoveryCancel?: (parentTaskId: string) => void
commsState?: import('../lib/wsClient').CommsStatePayload | null
commsMessage?: import('../lib/wsClient').CommsMessagePayload | null
onCommsRefresh?: (opts?: { task_id?: string; session_id?: string; project_id?: string }) => void
@@ -305,9 +301,6 @@ export function WorkspacePage({
onCollabSync,
orgInfoData,
onNavigateToOrg,
recoveryStatus,
onRecoveryResume,
onRecoveryCancel,
commsState,
commsMessage,
onCommsRefresh,
@@ -988,13 +981,21 @@ export function WorkspacePage({
const handleResume = useCallback(() => {
const targetSession = activeConversation.runtimeSession ?? activeConversation.displaySession ?? activeSession
const targetTaskId = targetSession?.resumeParentTaskId ?? targetSession?.taskId ?? activeSessionId
if (targetTaskId) onSessionResume?.(targetTaskId)
const uiTaskId = activeSessionId ?? targetSession?.taskId
const runtimeSessionId = targetSession?.resumeParentSessionId
?? targetSession?.parentSessionId
?? targetSession?.sessionId
if (uiTaskId) {
onSessionResume?.(uiTaskId, runtimeSessionId, targetSession?.pendingRuntimeCheckpointId)
}
}, [activeConversation.runtimeSession, activeConversation.displaySession, activeSession, activeSessionId, onSessionResume])
const handleResumeTask = useCallback((taskId: string) => {
const session = sessions.find(s => s.taskId === taskId)
onSessionResume?.(session?.resumeParentTaskId ?? taskId)
const runtimeSessionId = session?.resumeParentSessionId
?? session?.parentSessionId
?? session?.sessionId
onSessionResume?.(taskId, runtimeSessionId, session?.pendingRuntimeCheckpointId)
}, [sessions, onSessionResume])
const handleCompleteTask = useCallback((taskId: string) => {
@@ -1034,8 +1035,11 @@ export function WorkspacePage({
}
const targetTaskId = activeSessionId
if (!targetTaskId) return
const checkpointReplyId = String(latestPendingCheckpointReply?.response_to_checkpoint_id ?? '').trim()
const runtimeSession = activeConversation.runtimeSession ?? activeConversation.displaySession ?? activeSession
const runtimeCheckpointId = String(runtimeSession?.pendingRuntimeCheckpointId ?? '').trim()
let outgoingMetadata = latestPendingCheckpointReply
?? (runtimeCheckpointId ? { response_to_checkpoint_id: runtimeCheckpointId } : undefined)
const checkpointReplyId = String(outgoingMetadata?.response_to_checkpoint_id ?? '').trim()
if (!checkpointReplyId) {
const uiMessageId = makeOptimisticUserMessageId()
outgoingMetadata = { ...(latestPendingCheckpointReply ?? {}), ui_message_id: uiMessageId }
@@ -1050,7 +1054,7 @@ export function WorkspacePage({
}
dispatchSessionSend(targetTaskId, content, attachments, outgoingMetadata)
},
[effectiveView.kind, activeSessionId, activeConversation.displaySession, activeSession, latestPendingCheckpointReply, chatStore, dispatchSessionSend, onSecretarySend],
[effectiveView.kind, activeSessionId, activeConversation.runtimeSession, activeConversation.displaySession, activeSession, latestPendingCheckpointReply, chatStore, dispatchSessionSend, onSecretarySend],
)
// ── MessageList send (checkpoint replies) ──
@@ -1114,13 +1118,6 @@ export function WorkspacePage({
{/* Middle column: Kanban Board (hidden when panel maximized) */}
{panelState !== 'maximized' && (
<div className="workspace-board">
{recoveryStatus && onRecoveryResume && onRecoveryCancel && (
<WorkItemRecoveryPanel
data={recoveryStatus}
onResume={onRecoveryResume}
onCancel={onRecoveryCancel}
/>
)}
{agents.length > 0 && <AgentStatusBar agents={agents} tasks={boardStore.tasks} />}
{isCompanyMode ? (
boardStore.activeBoard && activeSession && (
@@ -1206,7 +1203,6 @@ export function WorkspacePage({
onCommsRefresh={onCommsRefresh ? () => onCommsRefresh({ session_id: activeSession?.sessionId || undefined, project_id: projectId || undefined }) : undefined}
onCommsReadMessage={onCommsReadMessage}
orgInfoData={orgInfoData ?? null}
recoveryStatus={recoveryStatus ?? null}
canShowTeamTab={canShowTeamTab}
onTeamStopRun={activeSessionId ? () => onSessionStop?.(activeSessionId) : undefined}
onTitleChange={onTitleChange}
@@ -1605,132 +1605,6 @@
}
}
/* ── Work Item Recovery Panel ────────────────────────────────────────── */
.wfr-panel {
padding: 0 8px;
}
.wfr-card {
background: color-mix(in srgb, var(--yellow, #f39c12) 8%, var(--bg-secondary));
border: 1px solid color-mix(in srgb, var(--yellow, #f39c12) 25%, transparent);
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 8px;
}
.wfr-header {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 8px;
}
.wfr-icon {
font-size: 16px;
line-height: 1;
flex-shrink: 0;
}
.wfr-header-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.wfr-title {
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.wfr-subtitle {
font-size: 11px;
color: var(--text-secondary);
}
.wfr-work-items {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.wfr-work-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.wfr-work-item-icon {
font-size: 12px;
width: 14px;
text-align: center;
flex-shrink: 0;
font-weight: 700;
}
.wfr-work-item-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wfr-work-item-badge {
font-size: 9px;
padding: 1px 5px;
border-radius: 3px;
background: color-mix(in srgb, var(--red, #e74c3c) 15%, transparent);
color: var(--red, #e74c3c);
flex-shrink: 0;
}
.wfr-actions {
display: flex;
gap: 8px;
align-items: center;
}
.wfr-btn {
padding: 5px 14px;
font-size: 12px;
font-weight: 500;
border: none;
border-radius: 6px;
cursor: pointer;
}
.wfr-btn--resume {
background: var(--green, #27ae60);
color: #fff;
}
.wfr-btn--resume:hover { opacity: 0.85; }
.wfr-btn--cancel {
background: var(--red, #e74c3c);
color: #fff;
}
.wfr-btn--cancel:hover { opacity: 0.85; }
.wfr-btn--dismiss {
background: transparent;
color: var(--text-secondary);
}
.wfr-btn--dismiss:hover { color: var(--text); }
.wfr-recovering {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--accent);
}
/* ═══════════════════════════════════════════════════════
Kanban empty-state
═══════════════════════════════════════════════════════ */
-552
View File
@@ -1,552 +0,0 @@
"""Pluggable work-item crash-recovery manager.
Detects interrupted company-mode work items after server restart and provides
deterministic Resume / Cancel operations — no LLM needed.
Hooks into existing engine capabilities without modifying core code:
- engine.store → task queries + persistence
- engine._load_company_runtime_snapshot() → reconstruct work-item plan + tasks
- engine.company_executor.execute() → resume execution
- ws_handler.broadcast() → push status to all clients
"""
from __future__ import annotations
import asyncio
import dataclasses
import logging
import time
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Awaitable, Callable
from opc.layer2_organization.work_item_identity import work_item_projection_id_from_metadata
from opc.layer2_organization.work_item_transition import apply_task_status_transition
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Data types
# ---------------------------------------------------------------------------
@dataclass
class RecoverableWorkItem:
work_item_projection_id: str
title: str
task_id: str
status: str # "done" | "failed" | "pending" | "blocked" | "cancelled"
interrupted: bool # has interrupted_recovery metadata
previous_status: str # what it was before reconciliation
@dataclass
class InterruptedWorkItemRun:
parent_session_id: str
parent_task_id: str
project_id: str
title: str
profile: str
interrupted_at: str
work_items: list[RecoverableWorkItem] = field(default_factory=list)
@dataclass
class RecoveryStatus:
interrupted: list[InterruptedWorkItemRun] = field(default_factory=list)
active_recoveries: list[str] = field(default_factory=list)
scanned_at: float = 0.0
# ---------------------------------------------------------------------------
# Manager
# ---------------------------------------------------------------------------
class RuntimeRecoveryManager:
"""Scans for interrupted work-item runs and provides deterministic recovery."""
_CACHE_TTL = 10.0 # seconds
def __init__(
self,
engine: Any,
broadcast_fn: Callable[[dict[str, Any]], Awaitable[None]],
) -> None:
self._engine = engine
self._broadcast = broadcast_fn
self._lock = asyncio.Lock()
self._active_recoveries: dict[str, asyncio.Task[Any]] = {}
self._cached: RecoveryStatus | None = None
self._cache_until: float = 0.0
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
async def get_recovery_status(self) -> RecoveryStatus:
"""Return cached scan results, re-scanning if stale."""
now = time.time()
if self._cached is not None and now < self._cache_until:
# Keep active_recoveries up to date even from cache
self._cached.active_recoveries = list(self._active_recoveries.keys())
return self._cached
status = await self.scan()
self._cached = status
self._cache_until = now + self._CACHE_TTL
return status
async def scan(self) -> RecoveryStatus:
"""Scan the database for recoverable interrupted work-item runs."""
store = self._engine.store
if not store:
return RecoveryStatus()
project_id = self._engine.project_id or "default"
try:
all_tasks = await store.get_tasks(project_id=project_id)
except Exception as exc:
logger.warning(f"Recovery scan failed: {exc}")
return RecoveryStatus()
# Group projected work-item tasks by parent_session_id.
groups: dict[str, list[Any]] = {}
all_tasks_by_session: dict[str, Any] = {}
for task in all_tasks:
session_id = str(getattr(task, "session_id", "") or "").strip()
if session_id:
all_tasks_by_session[session_id] = task
parent_sid = str(getattr(task, "parent_session_id", "") or "").strip()
projection_id = work_item_projection_id_from_metadata(getattr(task, "metadata", {}) or {})
if parent_sid and projection_id:
groups.setdefault(parent_sid, []).append(task)
interrupted: list[InterruptedWorkItemRun] = []
for parent_sid, tasks in groups.items():
# Check if any task has interrupted_recovery metadata
has_interrupted = any(
_is_interrupted(t) for t in tasks
)
if not has_interrupted:
continue
# Skip if all tasks are terminal (DONE or CANCELLED)
from opc.core.models import TaskStatus
non_terminal = [
t for t in tasks
if t.status not in (TaskStatus.DONE, TaskStatus.CANCELLED)
]
if not non_terminal:
continue
# Find the parent (primary) task
parent_task = all_tasks_by_session.get(parent_sid)
parent_task_id = parent_task.id if parent_task else parent_sid
title = parent_task.title if parent_task else "Unknown work-item run"
# Build work-item list.
work_items: list[RecoverableWorkItem] = []
earliest_interrupt = ""
for t in sorted(tasks, key=lambda x: (x.created_at, x.id)):
meta = dict(getattr(t, "metadata", {}) or {})
recovery_meta = meta.get("interrupted_recovery", {})
is_int = _is_interrupted(t)
if is_int and recovery_meta.get("detected_at", ""):
detected = recovery_meta["detected_at"]
if not earliest_interrupt or detected < earliest_interrupt:
earliest_interrupt = detected
work_items.append(RecoverableWorkItem(
work_item_projection_id=work_item_projection_id_from_metadata(meta, fallback=t.id),
title=t.title,
task_id=t.id,
status=t.status.value if hasattr(t.status, "value") else str(t.status),
interrupted=is_int,
previous_status=recovery_meta.get("previous_status", ""),
))
profile = ""
for t in tasks:
p = (getattr(t, "metadata", {}) or {}).get("company_profile", "")
if p:
profile = p
break
interrupted.append(InterruptedWorkItemRun(
parent_session_id=parent_sid,
parent_task_id=parent_task_id,
project_id=project_id,
title=title,
profile=profile,
interrupted_at=earliest_interrupt or datetime.now().isoformat(),
work_items=work_items,
))
return RecoveryStatus(
interrupted=interrupted,
active_recoveries=list(self._active_recoveries.keys()),
scanned_at=time.time(),
)
async def resume(self, parent_task_id: str) -> dict[str, Any]:
"""Deterministically resume an interrupted work-item run."""
async with self._lock:
if parent_task_id in self._active_recoveries:
return {"ok": False, "error": "already_in_progress"}
# Find the interrupted work-item run.
status = await self.scan()
wf = next((w for w in status.interrupted if w.parent_task_id == parent_task_id), None)
if not wf:
return {"ok": False, "error": "not_found"}
# Load snapshot
snapshot = await self._engine._load_company_runtime_snapshot(wf.parent_session_id)
if not snapshot:
return {"ok": False, "error": "snapshot_unavailable"}
plan, tasks = snapshot
# Clean orphaned checkpoints
await self._clean_orphaned_checkpoints(wf, tasks)
# Reset interrupted/failed/blocked tasks → PENDING
from opc.core.models import TaskStatus
resumed_ids: list[str] = []
failed_ids: list[str] = []
for task in tasks:
if task.status == TaskStatus.DONE:
continue
if task.status in (TaskStatus.FAILED, TaskStatus.BLOCKED):
task.result = None
task.execution_lock = False
task.execution_locked_at = None
meta = dict(task.metadata)
meta.pop("interrupted_recovery", None)
progress = list(meta.get("progress_log", []))
progress.append(f"[Recovery] Resumed at {datetime.now().isoformat()}")
meta["progress_log"] = progress[-20:]
task.metadata = meta
projection_id = work_item_projection_id_from_metadata(meta, fallback=task.id)
try:
await apply_task_status_transition(
self._engine.store,
task,
target_status_or_phase=TaskStatus.PENDING,
reason="office_recovery_resume",
release_claim=True,
)
except Exception as exc:
logger.warning("Recovery resume skipped %s: %s", task.id, exc)
failed_ids.append(projection_id)
continue
if task.status != TaskStatus.PENDING:
logger.warning("Recovery resume preserved non-runnable phase for %s", task.id)
failed_ids.append(projection_id)
continue
await self._engine.store.save_task(task)
resumed_ids.append(projection_id)
if not resumed_ids:
return {
"ok": False,
"error": "no_work_items_to_resume",
"failed_work_item_projection_ids": failed_ids,
}
# Invalidate cache
self._cache_until = 0.0
# Launch execution in background
await self._set_run_recovery_state(
wf.parent_session_id,
status="resuming",
lifecycle_status="active",
)
bg_task = asyncio.create_task(
self._execute_recovery(parent_task_id, wf.parent_session_id, plan, tasks)
)
self._active_recoveries[parent_task_id] = bg_task
return {
"ok": True,
"resumed_work_item_projection_ids": resumed_ids,
"failed_work_item_projection_ids": failed_ids,
}
async def cancel(self, parent_task_id: str) -> dict[str, Any]:
"""Cancel an interrupted work-item run and clean up."""
async with self._lock:
# Cancel active recovery if running
bg = self._active_recoveries.pop(parent_task_id, None)
if bg and not bg.done():
bg.cancel()
# Find the interrupted work-item run.
status = await self.scan()
wf = next((w for w in status.interrupted if w.parent_task_id == parent_task_id), None)
if not wf:
return {"ok": False, "error": "not_found"}
# Load tasks and cancel non-terminal ones
snapshot = await self._engine._load_company_runtime_snapshot(wf.parent_session_id)
if not snapshot:
return {"ok": False, "error": "snapshot_unavailable"}
_, tasks = snapshot
from opc.core.models import TaskStatus
cancelled_count = 0
failed_ids: list[str] = []
for task in tasks:
if task.status not in (TaskStatus.DONE, TaskStatus.CANCELLED):
projection_id = work_item_projection_id_from_metadata(
getattr(task, "metadata", {}) or {},
fallback=task.id,
)
try:
await apply_task_status_transition(
self._engine.store,
task,
target_status_or_phase=TaskStatus.CANCELLED,
reason="office_recovery_cancel",
release_claim=True,
)
except Exception as exc:
logger.warning("Recovery cancel skipped %s: %s", task.id, exc)
failed_ids.append(projection_id)
continue
if task.status != TaskStatus.CANCELLED:
logger.warning("Recovery cancel preserved non-cancelled phase for %s", task.id)
failed_ids.append(projection_id)
continue
cancelled_count += 1
# Clean orphaned checkpoints
await self._clean_orphaned_checkpoints(wf, tasks)
# Invalidate cache
self._cache_until = 0.0
await self._set_run_recovery_state(
wf.parent_session_id,
status="cancelled",
lifecycle_status="cancelled",
)
await self._broadcast_status()
return {
"ok": True,
"cancelled_count": cancelled_count,
"failed_work_item_projection_ids": failed_ids,
}
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
async def _execute_recovery(
self,
parent_task_id: str,
parent_session_id: str,
plan: Any,
tasks: list[Any],
) -> None:
"""Run the work-item executor in the background."""
project_id = self._engine.project_id or "default"
try:
await self._set_run_recovery_state(
parent_session_id,
status="started",
lifecycle_status="active",
)
await self._broadcast({"type": "recovery_result", "payload": {
"project_id": project_id,
"parent_task_id": parent_task_id, "status": "started",
}})
executor = self._engine.company_executor
if not executor:
raise RuntimeError("company_executor not available")
result = await executor.execute(plan, tasks)
await self._broadcast({"type": "recovery_result", "payload": {
"project_id": project_id,
"parent_task_id": parent_task_id, "status": "completed",
"summary": result[:500] if result else "",
}})
await self._set_run_recovery_state(
parent_session_id,
status="completed",
lifecycle_status="active",
)
except asyncio.CancelledError:
await self._broadcast({"type": "recovery_result", "payload": {
"project_id": project_id,
"parent_task_id": parent_task_id, "status": "cancelled",
}})
await self._set_run_recovery_state(
parent_session_id,
status="cancelled",
lifecycle_status="cancelled",
)
except Exception as exc:
logger.warning(f"Recovery execution failed for {parent_task_id}: {exc}")
await self._broadcast({"type": "recovery_result", "payload": {
"project_id": project_id,
"parent_task_id": parent_task_id, "status": "failed",
"error": str(exc),
}})
await self._set_run_recovery_state(
parent_session_id,
status="failed",
lifecycle_status="blocked",
extra={"error": str(exc)},
)
finally:
self._active_recoveries.pop(parent_task_id, None)
self._cache_until = 0.0
await self._broadcast_status()
async def _clean_orphaned_checkpoints(
self,
wf: InterruptedWorkItemRun,
tasks: list[Any],
) -> int:
"""Resolve pending checkpoints whose tasks are no longer active."""
store = self._engine.store
if not store:
return 0
session_ids = {
str(getattr(t, "session_id", "") or "").strip()
for t in tasks
}
session_ids.add(wf.parent_session_id)
session_ids.discard("")
cleaned = 0
try:
pending = await store.get_pending_checkpoints(
project_id=wf.project_id,
)
for cp in pending:
cp_session = str(cp.session_id or "").strip()
if cp_session in session_ids:
await store.resolve_execution_checkpoint(
cp.checkpoint_id, status="cancelled"
)
cleaned += 1
except Exception as exc:
logger.debug(f"Checkpoint cleanup error: {exc}")
return cleaned
async def _broadcast_status(self) -> None:
"""Push updated recovery status to all connected clients."""
try:
status = await self.get_recovery_status()
await self._broadcast({"type": "recovery_status", "payload":
_serialize_status(status, project_id=self._engine.project_id or "default")
})
except Exception as exc:
logger.debug(f"Recovery status broadcast failed: {exc}")
def _invalidate_cache(self) -> None:
"""Force next get_recovery_status to re-scan."""
self._cache_until = 0.0
self._cached = None
async def _set_run_recovery_state(
self,
key: str,
*,
status: str,
lifecycle_status: str | None = None,
match_task_id: bool = False,
extra: dict[str, Any] | None = None,
) -> None:
store = getattr(self._engine, "store", None)
if not store or not hasattr(store, "list_delegation_runs") or not hasattr(store, "save_delegation_run"):
return
runs = await store.list_delegation_runs(project_id=self._engine.project_id or "default")
target = None
if match_task_id:
for run in runs:
metadata = dict(getattr(run, "metadata", {}) or {})
if str(metadata.get("origin_task_id", "") or "").strip() == key:
target = run
break
if target is None:
for run in runs:
if str(run.session_id or "").strip() == key:
target = run
break
if target is None:
return
target.recovery_pointer = {
**dict(getattr(target, "recovery_pointer", {}) or {}),
"status": status,
"updated_at": datetime.now().isoformat(),
**dict(extra or {}),
}
if lifecycle_status:
target.lifecycle_status = lifecycle_status
target.updated_at = datetime.now()
await store.save_delegation_run(target)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _is_interrupted(task: Any) -> bool:
"""Check if a task was interrupted by crash."""
from opc.core.models import TaskStatus
if task.status != TaskStatus.FAILED:
return False
meta = getattr(task, "metadata", {}) or {}
if meta.get("interrupted_recovery"):
return True
result = getattr(task, "result", {}) or {}
artifacts = result.get("artifacts", {}) or {}
return bool(artifacts.get("interrupted"))
def _serialize_status(status: RecoveryStatus, *, project_id: str | None = None) -> dict[str, Any]:
"""Convert RecoveryStatus to JSON-safe dict."""
resolved_project_id = str(project_id or "").strip()
if not resolved_project_id:
for item in status.interrupted:
if item.project_id:
resolved_project_id = item.project_id
break
payload = {
"interrupted": [
{
"parent_session_id": w.parent_session_id,
"parent_task_id": w.parent_task_id,
"project_id": w.project_id,
"title": w.title,
"profile": w.profile,
"interrupted_at": w.interrupted_at,
"work_items": [
{
"work_item_projection_id": s.work_item_projection_id,
"title": s.title,
"task_id": s.task_id,
"status": s.status,
"interrupted": s.interrupted,
"previous_status": s.previous_status,
}
for s in w.work_items
],
}
for w in status.interrupted
],
"active_recoveries": status.active_recoveries,
"scanned_at": status.scanned_at,
}
if resolved_project_id:
payload["project_id"] = resolved_project_id
return payload
-11
View File
@@ -144,17 +144,6 @@ async def create_app(
engine.event_bus.subscribe_all(_root_engine_event)
# ── Runtime crash recovery (pluggable, no engine modifications) ──
from opc.plugins.office_ui.recovery_manager import RuntimeRecoveryManager
recovery_manager = RuntimeRecoveryManager(engine, ws_handler.broadcast)
ws_handler.recovery_manager = recovery_manager
# ── Startup self-heal for tasks abandoned by a prior process ─────
# Must run before restoring persisted mode and before any WS client can
# connect, so orphaned running/locked rows do not block new Continue /
# session_send acquisitions.
await ws_handler.heal_orphan_tasks_on_boot()
# ── Restore persisted mode and load matching agents on startup ───
await ws_handler.restore_persisted_mode()
startup_preset = ws_handler._resolve_preset_name()
+10 -1
View File
@@ -32,4 +32,13 @@ class ServiceError(Exception):
self.payload = dict(payload or {})
def to_payload(self) -> dict[str, Any]:
return {"error": self.message, "code": self.code, **self.payload}
# Transport envelope fields are authoritative. Business details may
# add context, but must never turn an error acknowledgement into a
# success or replace the exception's code/message.
payload = {
key: value
for key, value in self.payload.items()
if key not in {"ok", "error", "code"}
}
payload.update({"error": self.message, "code": self.code})
return payload
-45
View File
@@ -200,48 +200,3 @@ class RuntimeService:
payload["display_text"] = " | ".join(display_parts)
payload["event_type"] = event_type
return payload
async def recovery_scan(self, *, project_id: str) -> ServiceResult:
manager = await self._recovery_manager(project_id)
from opc.plugins.office_ui.recovery_manager import _serialize_status
status = await manager.get_recovery_status()
return ServiceResult(_serialize_status(status, project_id=project_id))
async def recovery_action(self, *, project_id: str, action: str, parent_task_id: str) -> ServiceResult:
manager = await self._recovery_manager(project_id)
normalized = str(action or "").strip().lower()
if normalized == "scan":
return await self.recovery_scan(project_id=project_id)
if not str(parent_task_id or "").strip():
raise ServiceError("parent_task_id_required", "parent_task_id required")
if normalized in {"resume", "retry"}:
payload = await manager.resume(parent_task_id)
elif normalized == "cancel":
payload = await manager.cancel(parent_task_id)
else:
raise ServiceError("unknown_recovery_action", f"unknown action: {action}", {"action": action})
payload = {**dict(payload), "project_id": project_id, "parent_task_id": parent_task_id, "action": normalized}
if not payload.get("ok", False):
raise ServiceError(str(payload.get("error") or "recovery_failed"), str(payload.get("error") or "recovery_failed"), payload)
return ServiceResult(payload)
async def _recovery_manager(self, project_id: str) -> Any:
engine = await self.context.engine_for_project(project_id)
async def _noop_broadcast(_event: dict[str, Any]) -> None:
return None
managers = getattr(self.context, "recovery_managers", None)
if managers is None:
managers = {}
setattr(self.context, "recovery_managers", managers)
key = self.context.normalize_project_id(project_id)
existing = managers.get(key)
if existing is not None and getattr(existing, "_engine", None) is engine:
return existing
from opc.plugins.office_ui.recovery_manager import RuntimeRecoveryManager
manager = RuntimeRecoveryManager(engine, _noop_broadcast)
managers[key] = manager
return manager
+305 -182
View File
@@ -12,6 +12,12 @@ from typing import Any
from loguru import logger
from opc.core.models import Task, TaskStatus
from opc.layer2_organization.company_runtime_identity import (
ACTIVE_COMPANY_RUNTIME_CHECKPOINT_STATUSES,
COMPANY_RUNTIME_CHECKPOINT_TYPES,
is_company_runtime_task,
load_company_runtime_identity_index,
)
from opc.plugins.office_ui.execution_identity import (
ExecutionIdentity,
canonicalize_execution_identity,
@@ -156,6 +162,32 @@ class SessionService:
task_project = self.context.normalize_project_id(getattr(task, "project_id", None))
if task_project != pid:
raise ServiceError("target_wrong_project", "Target belongs to a different project", {"project_id": task_project})
# Company control is resolved from durable session/checkpoint scope
# before any generic Task/session fallback. This prevents a shared
# final-decider row from winning merely because it was loaded first.
identity_index = await load_company_runtime_identity_index(store, pid)
if task is not None:
identity = identity_index.resolve(task_id=raw_target)
else:
identity = (
identity_index.resolve(runtime_session_id=raw_target)
or identity_index.resolve(task_session_id=raw_target)
)
if identity is not None:
# A concrete Task id is the caller's UI/chat channel, never the
# company execution identity. Keep that channel stable while the
# shared identity supplies the durable runtime session/checkpoint.
if task is not None:
return task, identity.runtime_session_id
resolved_task = (
identity_index.task(identity.ui_anchor_task_id)
or identity_index.task(identity.config_source_task_id)
)
if resolved_task is not None:
return resolved_task, identity.runtime_session_id
if task is not None:
return task, str(getattr(task, "session_id", "") or getattr(task, "parent_session_id", "") or "")
session = await store.get_session(raw_target) if hasattr(store, "get_session") else None
@@ -165,139 +197,64 @@ class SessionService:
if session_project != pid:
raise ServiceError("target_wrong_project", "Target belongs to a different project", {"project_id": session_project})
tasks = await store.get_tasks(project_id=pid) if hasattr(store, "get_tasks") else []
tasks = list(identity_index.tasks)
session_tasks = [
candidate for candidate in tasks
if str(getattr(candidate, "session_id", "") or "") == raw_target
]
if not session_tasks:
raise ServiceError("session_not_task_backed", "Session is not linked to a task-backed runtime", {"session_id": raw_target})
session_tasks.sort(key=lambda item: bool(str(getattr(item, "parent_session_id", "") or "")))
return session_tasks[0], raw_target
task_mode_anchor = min(
session_tasks,
key=lambda item: (
bool(str(getattr(item, "parent_session_id", "") or "")),
str(getattr(item, "created_at", "") or ""),
str(getattr(item, "id", "") or ""),
),
)
return task_mode_anchor, raw_target
async def _resolve_company_runtime_target(self, engine: Any, task: Any) -> dict[str, Any]:
store = getattr(engine, "store", None)
parent_session_id = str(
getattr(task, "parent_session_id", "")
or getattr(task, "session_id", "")
or ""
).strip()
parent_task_id = str(self.context.session_to_task.get(parent_session_id) or "").strip()
project_id = self.context.normalize_project_id(getattr(task, "project_id", None) or getattr(engine, "project_id", None))
try:
project_tasks = await store.get_tasks(project_id=project_id) if hasattr(store, "get_tasks") else [task]
except Exception:
project_tasks = [task]
for candidate in project_tasks:
candidate_id = str(getattr(candidate, "id", "") or "").strip()
candidate_session_id = str(getattr(candidate, "session_id", "") or "").strip()
candidate_parent_session_id = str(getattr(candidate, "parent_session_id", "") or "").strip()
if candidate_session_id == parent_session_id and not candidate_parent_session_id:
parent_task_id = candidate_id
break
if not parent_task_id:
parent_task_id = str(
self.context.active_runtime_children.get(str(getattr(task, "id", "") or ""))
or getattr(task, "id", "")
or ""
).strip()
affected_task_ids: list[str] = []
for candidate in project_tasks:
candidate_id = str(getattr(candidate, "id", "") or "").strip()
if not candidate_id:
continue
candidate_session_id = str(getattr(candidate, "session_id", "") or "").strip()
candidate_parent_session_id = str(getattr(candidate, "parent_session_id", "") or "").strip()
if (
candidate_id == str(getattr(task, "id", "") or "")
or candidate_id == parent_task_id
or candidate_session_id == parent_session_id
or candidate_parent_session_id == parent_session_id
):
if candidate_id not in affected_task_ids:
affected_task_ids.append(candidate_id)
for child_id, origin_id in list(self.context.active_runtime_children.items()):
if origin_id == parent_task_id or child_id == str(getattr(task, "id", "") or ""):
if child_id not in affected_task_ids:
affected_task_ids.append(child_id)
if parent_task_id and parent_task_id not in affected_task_ids:
affected_task_ids.insert(0, parent_task_id)
return {
"parent_session_id": parent_session_id,
"parent_task_id": parent_task_id or str(getattr(task, "id", "") or ""),
"origin_task_id": parent_task_id or str(getattr(task, "id", "") or ""),
"affected_task_ids": affected_task_ids or [str(getattr(task, "id", "") or "")],
}
async def _mark_company_runtime_stop_state(
async def _resolve_company_runtime_target(
self,
*,
engine: Any,
task_ids: list[str],
state: str,
stop_intent_id: str,
checkpoint_type: str = "",
) -> None:
task: Any,
*,
runtime_session_id: str = "",
checkpoint_id: str = "",
) -> dict[str, Any]:
store = getattr(engine, "store", None)
project_id = self.context.normalize_project_id(getattr(task, "project_id", None) or getattr(engine, "project_id", None))
if not self.context.store_is_ready(store):
return
for task_id in task_ids:
try:
task = await store.get_task(str(task_id))
except Exception:
task = None
if not task or self._is_terminal_status(task):
continue
metadata = dict(getattr(task, "metadata", {}) or {})
metadata["company_runtime_stop_state"] = state
metadata["company_runtime_stop_intent_id"] = stop_intent_id
metadata["company_runtime_stop_marked_at"] = datetime.now().isoformat()
metadata["dispatch_hold"] = "company_runtime_suspended"
metadata["company_runtime_suspended_at"] = datetime.now().isoformat()
if checkpoint_type:
metadata["company_runtime_suspend_checkpoint_type"] = checkpoint_type
metadata.setdefault("suspended_task_status", self._task_status_value(task))
task.metadata = metadata
task.status = TaskStatus.BLOCKED
if hasattr(task, "execution_lock"):
task.execution_lock = False
if hasattr(task, "execution_locked_at"):
task.execution_locked_at = None
try:
await store.save_task(task)
except Exception:
logger.opt(exception=True).debug("failed to mark company runtime stop state")
async def _clear_company_runtime_stop_state(self, *, engine: Any, task_ids: list[str]) -> None:
store = getattr(engine, "store", None)
if not self.context.store_is_ready(store):
return
for task_id in task_ids:
try:
task = await store.get_task(str(task_id))
except Exception:
task = None
if not task:
continue
metadata = dict(getattr(task, "metadata", {}) or {})
for key in (
"dispatch_hold",
"company_runtime_stop_state",
"company_runtime_stop_intent_id",
"company_runtime_stop_marked_at",
"company_runtime_suspend_checkpoint_type",
"company_runtime_suspended_at",
"suspended_task_status",
):
metadata.pop(key, None)
task.metadata = metadata
if self._task_status_value(task) == "blocked":
task.status = TaskStatus.IDLE
try:
await store.save_task(task)
except Exception:
logger.opt(exception=True).debug("failed to clear company runtime stop state")
raise ServiceError("store_not_ready", "store_not_ready", {"project_id": project_id})
index = await load_company_runtime_identity_index(store, project_id)
identity = index.resolve(
task_id=str(getattr(task, "id", "") or ""),
runtime_session_id=runtime_session_id,
checkpoint_id=checkpoint_id,
)
if identity is None:
raise ServiceError(
"company_runtime_identity_mismatch",
"Company runtime identity does not match the requested task/session/checkpoint",
{
"task_id": str(getattr(task, "id", "") or ""),
"runtime_session_id": str(runtime_session_id or ""),
"checkpoint_id": str(checkpoint_id or ""),
},
)
config_task = index.task(identity.config_source_task_id) or task
ui_anchor_task_id = identity.ui_anchor_task_id
return {
"identity": identity,
"runtime_session_id": identity.runtime_session_id,
"ui_channel_task_id": str(getattr(task, "id", "") or ""),
"ui_anchor_task_id": ui_anchor_task_id,
"config_source_task_id": identity.config_source_task_id,
"config_task": config_task,
"origin_task_id": ui_anchor_task_id,
"affected_task_ids": list(identity.runtime_task_ids),
"checkpoint": identity.checkpoint,
}
def _normalize_requested_config(
self,
@@ -743,8 +700,54 @@ class SessionService:
if getattr(engine, "memory", None):
await engine.memory.ensure_session(task.session_id, project_id=project_id, title=task.title, mode="primary", metadata={"source": "service"})
await store.save_task(task)
# A Task selected by the UI/CLI is only the chat channel for company
# mode. Resolve the durable runtime scope before choosing execution
# configuration, session, origin, or checkpoint. In particular, a
# role/work-item Task must never become the parent execution identity.
company_target: dict[str, Any] | None = None
task_is_company_runtime = is_company_runtime_task(task)
try:
company_target = await self._resolve_company_runtime_target(engine, task)
except ServiceError as exc:
if exc.code != "company_runtime_identity_mismatch" or task_is_company_runtime:
raise
if task.status == TaskStatus.CANCELLED:
company_identity = (
company_target.get("identity")
if company_target is not None
else None
)
checkpoint = (
company_target.get("checkpoint")
if company_target is not None
else None
)
active_cancelled_anchor = bool(
company_identity is not None
and str(getattr(company_identity, "ui_anchor_task_id", "") or "").strip()
== str(getattr(task, "id", "") or "").strip()
and checkpoint is not None
and str(getattr(checkpoint, "checkpoint_type", "") or "").strip()
in COMPANY_RUNTIME_CHECKPOINT_TYPES
and str(getattr(checkpoint, "status", "") or "").strip().lower()
in ACTIVE_COMPANY_RUNTIME_CHECKPOINT_STATUSES
)
if not active_cancelled_anchor:
raise ServiceError(
"session_ended",
"session_ended",
{"project_id": project_id, "task_id": task.id},
)
config_task = (
company_target.get("config_task")
if company_target is not None
else task
) or task
identity = self.resolve_task_identity(
task,
config_task,
default_exec_mode=mode,
default_company_profile=company_profile if company_profile is not None else "corporate",
default_preferred_agent=preferred_agent if preferred_agent is not None else "native",
@@ -752,26 +755,85 @@ class SessionService:
)
if identity.is_custom_org and not identity.org_id:
raise ServiceError("org_id_required", "org_id_required", {"project_id": project_id, "task_id": task.id})
await self.persist_session_config(
task,
exec_mode=identity.exec_mode,
company_profile=identity.company_profile,
preferred_agent=identity.preferred_agent,
org_id=identity.org_id,
engine=engine,
)
# Existing company scopes read configuration from the resolver's
# config source. Only persist when that source is the selected Task;
# this preserves normal session configuration while avoiding writes to
# an internal work item merely because it was used as the UI channel.
if (
company_target is None
or str(getattr(config_task, "id", "") or "").strip()
== str(getattr(task, "id", "") or "").strip()
):
await self.persist_session_config(
task,
exec_mode=identity.exec_mode,
company_profile=identity.company_profile,
preferred_agent=identity.preferred_agent,
org_id=identity.org_id,
engine=engine,
)
execution_session_id = str(getattr(task, "session_id", "") or "").strip()
origin_task_id = str(getattr(task, "id", "") or "").strip() or None
message_metadata: dict[str, Any] | None = None
if company_target is not None:
execution_session_id = str(
company_target.get("runtime_session_id", "") or ""
).strip()
origin_task_id = str(
company_target.get("ui_anchor_task_id", "") or ""
).strip() or None
if not execution_session_id:
raise ServiceError(
"company_runtime_identity_mismatch",
"Company runtime has no canonical runtime session",
{"project_id": project_id, "task_id": task.id},
)
checkpoint = company_target.get("checkpoint")
if checkpoint is not None:
checkpoint_id = str(
getattr(checkpoint, "checkpoint_id", "") or ""
).strip()
checkpoint_status = str(
getattr(checkpoint, "status", "") or ""
).strip().lower()
if checkpoint_status != "pending":
raise ServiceError(
"company_runtime_checkpoint_not_pending",
"Company runtime checkpoint is not pending",
{
"project_id": project_id,
"task_id": task.id,
"checkpoint_id": checkpoint_id,
"checkpoint_status": checkpoint_status,
},
)
message_metadata = {
"response_to_checkpoint_id": checkpoint_id,
"response_to_checkpoint_type": str(
getattr(checkpoint, "checkpoint_type", "") or ""
).strip(),
}
response = await engine.process_message(
str(content or "").strip(),
project_id=project_id,
session_id=task.session_id,
session_id=execution_session_id,
mode=identity.exec_mode,
org_id=identity.org_id or None,
company_profile=identity.company_profile if identity.is_company_runtime else None,
preferred_agent=identity.preferred_agent if identity.is_task else None,
domains=list(domains or []),
origin_task_id=task.id,
origin_task_id=origin_task_id,
message_metadata=message_metadata,
)
return ServiceResult({"project_id": project_id, "task_id": task.id, "session_id": task.session_id, "response": response})
return ServiceResult({
"project_id": project_id,
"task_id": task.id,
"session_id": execution_session_id,
"response": response,
})
async def rename(self, *, project_id: str, task_id: str = "", session_id: str = "", title: str) -> ServiceResult:
pid = self.context.normalize_project_id(project_id)
@@ -935,50 +997,44 @@ class SessionService:
default_payload=default_payload,
)
engine = await self.context.engine_for_project(project_id)
exec_mode, _company_profile = self.resolve_task_session_config(task)
if exec_mode in {"company", "org", "custom"}:
try:
target_info = await self._resolve_company_runtime_target(engine, task)
except ServiceError as exc:
if exc.code != "company_runtime_identity_mismatch":
raise
target_info = None
if target_info is None and is_company_runtime_task(task):
raise ServiceError(
"company_runtime_identity_mismatch",
"Company runtime identity could not be resolved; refusing task-mode cancellation",
{"task_id": resolved_task_id, "session_id": resolved_session_id},
)
if target_info is not None:
stop_intent_id = str(uuid.uuid4())
affected_task_ids = list(target_info.get("affected_task_ids", []) or [resolved_task_id])
suspended: dict[str, Any] | None = None
suspend = getattr(engine, "suspend_company_runtime", None)
await self._mark_company_runtime_stop_state(
engine=engine,
task_ids=affected_task_ids,
state="suspending",
stop_intent_id=stop_intent_id,
)
if callable(suspend):
try:
suspended = await suspend(
origin_task_id=str(target_info.get("origin_task_id", "") or resolved_task_id),
session_id=(str(target_info.get("parent_session_id", "") or resolved_session_id).strip() or None),
session_id=(str(target_info.get("runtime_session_id", "") or resolved_session_id).strip() or None),
reason="user_stop",
checkpoint_type="company_runtime_suspended",
stop_intent_id=stop_intent_id,
)
except Exception:
logger.opt(exception=True).warning("suspend_company_runtime failed during service stop")
if suspended is not None:
for candidate in list(suspended.get("task_ids", []) or []):
candidate_id = str(candidate or "").strip()
if candidate_id and candidate_id not in affected_task_ids:
affected_task_ids.append(candidate_id)
await self._mark_company_runtime_stop_state(
engine=engine,
task_ids=affected_task_ids,
state="suspended",
stop_intent_id=stop_intent_id,
checkpoint_type=str(suspended.get("checkpoint_type", "") or "company_runtime_suspended"),
)
else:
await self._mark_company_runtime_stop_state(
engine=engine,
task_ids=affected_task_ids,
state="suspended",
stop_intent_id=stop_intent_id,
checkpoint_type="company_runtime_suspended",
if suspended is None:
raise ServiceError(
"company_runtime_suspend_failed",
"Company runtime could not be suspended",
{"runtime_session_id": target_info.get("runtime_session_id", "")},
)
for candidate in list(suspended.get("task_ids", []) or []):
candidate_id = str(candidate or "").strip()
if candidate_id and candidate_id not in affected_task_ids:
affected_task_ids.append(candidate_id)
self.context.stop_requested_task_ids.update(affected_task_ids)
if self.context.cancel_session_tasks is not None:
for tid in affected_task_ids:
@@ -1009,8 +1065,7 @@ class SessionService:
"stop_intent_id": stop_intent_id,
"checkpoint_id": str((suspended or {}).get("checkpoint_id", "") or ""),
"task_ids": affected_task_ids,
"resume_parent_task_id": str(target_info.get("parent_task_id", "") or resolved_task_id),
"resume_parent_session_id": str(target_info.get("parent_session_id", "") or resolved_session_id),
"resume_parent_session_id": str(target_info.get("runtime_session_id", "") or resolved_session_id),
}
return ServiceResult(payload, [ServiceEvent("session_runtime_control", payload), ServiceEvent("session_updated", payload)])
@@ -1039,6 +1094,8 @@ class SessionService:
project_id: str,
task_id: str = "",
session_id: str = "",
runtime_session_id: str = "",
checkpoint_id: str = "",
target: str = "",
content: str = "",
) -> ServiceResult:
@@ -1066,45 +1123,111 @@ class SessionService:
default_payload=default_payload,
)
engine = await self.context.engine_for_project(project_id)
exec_mode, company_profile = self.resolve_task_session_config(task)
target_info = await self._resolve_company_runtime_target(engine, task) if exec_mode in {"company", "org", "custom"} else {
try:
company_target = await self._resolve_company_runtime_target(
engine,
task,
runtime_session_id=runtime_session_id,
checkpoint_id=checkpoint_id,
)
except ServiceError as exc:
if exc.code != "company_runtime_identity_mismatch" or runtime_session_id or checkpoint_id:
raise
if is_company_runtime_task(task):
raise
company_target = None
target_info = company_target or {
"affected_task_ids": [resolved_task_id],
"parent_task_id": resolved_task_id,
"parent_session_id": resolved_session_id,
"ui_anchor_task_id": resolved_task_id,
"runtime_session_id": resolved_session_id,
"config_task": task,
"checkpoint": None,
}
affected_task_ids = list(target_info.get("affected_task_ids", []) or [resolved_task_id])
await self._clear_company_runtime_stop_state(engine=engine, task_ids=affected_task_ids)
message = str(content or "").strip() or "Resume the existing runtime."
engine_mode = "company" if exec_mode == "company" else ("org" if exec_mode in {"org", "custom"} else "task")
org_id = self.resolve_task_org_id(task) if engine_mode == "org" else ""
config_task = target_info.get("config_task") or task
config_exec_mode, config_company_profile = self.resolve_task_session_config(config_task)
engine_mode = "company" if config_exec_mode == "company" else (
"org" if config_exec_mode in {"org", "custom"} else "task"
)
if engine_mode in {"company", "org"}:
checkpoint = target_info.get("checkpoint")
if checkpoint is None:
raise ServiceError("company_runtime_checkpoint_not_found", "No active company runtime checkpoint")
if str(getattr(checkpoint, "status", "") or "").strip().lower() != "pending":
raise ServiceError(
"company_runtime_checkpoint_not_pending",
"Company runtime checkpoint is not pending",
{"checkpoint_id": str(getattr(checkpoint, "checkpoint_id", "") or "")},
)
else:
checkpoint = None
org_id = self.resolve_task_org_id(config_task) if engine_mode == "org" else ""
message_metadata: dict[str, Any] = {"ui_force_resume": True}
if checkpoint is not None:
message_metadata.update({
"response_to_checkpoint_id": str(getattr(checkpoint, "checkpoint_id", "") or ""),
"response_to_checkpoint_type": str(getattr(checkpoint, "checkpoint_type", "") or ""),
})
response = await engine.process_message(
message,
project_id=self.context.normalize_project_id(project_id),
session_id=str(target_info.get("parent_session_id", "") or resolved_session_id),
session_id=str(target_info.get("runtime_session_id", "") or resolved_session_id),
mode=engine_mode,
org_id=org_id or None,
company_profile=company_profile if engine_mode == "company" else None,
preferred_agent=self.resolve_task_preferred_agent(task) if engine_mode == "task" else None,
origin_task_id=str(target_info.get("parent_task_id", "") or resolved_task_id),
message_metadata={"ui_force_resume": True},
company_profile=config_company_profile if engine_mode == "company" else None,
preferred_agent=self.resolve_task_preferred_agent(config_task) if engine_mode == "task" else None,
origin_task_id=(str(target_info.get("ui_anchor_task_id", "") or "").strip() or None),
message_metadata=message_metadata,
)
runtime_control_state = "idle"
pending_runtime_checkpoint_id = ""
if engine_mode in {"company", "org"}:
refreshed_index = await load_company_runtime_identity_index(
engine.store,
self.context.normalize_project_id(project_id),
)
refreshed_identity = refreshed_index.resolve(
runtime_session_id=str(
target_info.get("runtime_session_id", "") or resolved_session_id
),
)
if refreshed_identity is not None and refreshed_identity.pending_checkpoint_id:
pending_runtime_checkpoint_id = refreshed_identity.pending_checkpoint_id
runtime_control_state = (
"suspended"
if refreshed_identity.pending_checkpoint_status == "pending"
else "resuming"
)
payload = {
**default_payload,
"status": "resuming",
"runtime_control_state": "resuming",
"can_resume": False,
"status": runtime_control_state,
"runtime_control_state": runtime_control_state,
"can_resume": runtime_control_state == "suspended",
"response": response,
"task_ids": affected_task_ids,
"resume_parent_task_id": str(target_info.get("parent_task_id", "") or resolved_task_id),
"resume_parent_session_id": str(target_info.get("parent_session_id", "") or resolved_session_id),
"resume_parent_session_id": str(target_info.get("runtime_session_id", "") or resolved_session_id),
"pending_runtime_checkpoint_id": pending_runtime_checkpoint_id,
}
return ServiceResult(payload, [ServiceEvent("session_runtime_control", payload), ServiceEvent("session_updated", payload)])
async def resume(self, *, project_id: str, task_id: str = "", session_id: str = "", target: str = "", content: str = "") -> ServiceResult:
async def resume(
self,
*,
project_id: str,
task_id: str = "",
session_id: str = "",
runtime_session_id: str = "",
checkpoint_id: str = "",
target: str = "",
content: str = "",
) -> ServiceResult:
return await self.continue_run(
project_id=project_id,
task_id=task_id,
session_id=session_id,
runtime_session_id=runtime_session_id,
checkpoint_id=checkpoint_id,
target=target,
content=content,
)
+71 -80
View File
@@ -39,6 +39,11 @@ from opc.layer2_organization.phase import (
should_hide_work_item_from_company_kanban,
verdict,
)
from opc.layer2_organization.company_runtime_identity import (
ACTIVE_COMPANY_RUNTIME_CHECKPOINT_STATUSES,
COMPANY_RUNTIME_CHECKPOINT_TYPES,
build_company_runtime_identity_index,
)
from opc.layer2_organization.work_item_context_view import WorkItemContextView
from opc.layer2_organization.work_item_identity import (
WORK_ITEM_PROJECTION_ID_KEY,
@@ -1503,6 +1508,11 @@ def _primary_session_tasks_by_session_id(
*,
task_meta_map: dict[str, dict[str, Any]] | None = None,
) -> tuple[dict[str, Any], list[str]]:
identity_index = build_company_runtime_identity_index(tasks)
company_identities = {
identity.runtime_session_id: identity
for identity in identity_index.identities
}
primary_tasks_by_session_id: dict[str, Any] = {}
ordered_session_ids: list[str] = []
for task in tasks:
@@ -1513,6 +1523,19 @@ def _primary_session_tasks_by_session_id(
if bool(task_meta.get("review_task", False)):
continue
session_id = str(getattr(task, "session_id", "") or "").strip()
company_identity = company_identities.get(session_id)
if company_identity is not None:
anchor = identity_index.task(company_identity.ui_anchor_task_id)
if anchor is not None and session_id not in primary_tasks_by_session_id:
primary_tasks_by_session_id[session_id] = anchor
ordered_session_ids.append(session_id)
if anchor is not None:
# A shared final-decider/work-item Task must never replace a
# pure UI anchor that owns the same session id.
continue
# Without a pure anchor this scope has no primary chat container.
# Never synthesize one from a role/work-item Task.
continue
if not session_id or _task_parent_session_link(task, task_meta):
continue
current = primary_tasks_by_session_id.get(session_id)
@@ -1530,31 +1553,16 @@ def _primary_session_tasks_by_session_id(
return primary_tasks_by_session_id, ordered_session_ids
def _shared_role_identity_tasks_by_session_id(
def _company_config_source_tasks_by_session_id(
tasks: list[Any],
*,
task_meta_map: dict[str, dict[str, Any]] | None = None,
) -> dict[str, Any]:
identity_tasks_by_session_id: dict[str, Any] = {}
for task in tasks:
task_id = str(getattr(task, "id", "") or "").strip()
task_meta = (
task_meta_map.get(task_id, {}) if task_meta_map is not None and task_id else _task_metadata(task)
)
session_id = _shared_role_session_key(task, task_meta)
if not session_id:
continue
current = identity_tasks_by_session_id.get(session_id)
if current is None:
identity_tasks_by_session_id[session_id] = task
continue
current_id = str(getattr(current, "id", "") or "").strip()
current_meta = (
task_meta_map.get(current_id, {}) if task_meta_map is not None and current_id else _task_metadata(current)
)
if _session_representative_rank(task, task_meta) > _session_representative_rank(current, current_meta):
identity_tasks_by_session_id[session_id] = task
return identity_tasks_by_session_id
identity_index = build_company_runtime_identity_index(tasks)
return {
identity.runtime_session_id: task
for identity in identity_index.identities
if identity.config_source_task_id
and (task := identity_index.task(identity.config_source_task_id)) is not None
}
async def build_company_kanban_projection(
@@ -2594,31 +2602,7 @@ async def _build_company_runtime_control_by_task(
if not store:
return {}
parent_task_by_session: dict[str, str] = {}
tasks_by_parent_session: dict[str, list[Any]] = {}
for task in tasks:
metadata = dict(getattr(task, "metadata", {}) or {})
mode = str(metadata.get("mode", "") or metadata.get("exec_mode", "") or "").strip().lower()
is_company_runtime_task = bool(
mode in {"company", "org", "custom"}
or str(getattr(task, "parent_session_id", "") or "").strip()
or metadata.get("company_profile")
or metadata.get("company_work_item_plan")
or metadata.get("work_item_runtime")
or metadata.get("work_item_projection_id")
)
if not is_company_runtime_task:
continue
session_id = str(getattr(task, "session_id", "") or "").strip()
parent_session_id = str(getattr(task, "parent_session_id", "") or "").strip()
task_id = str(getattr(task, "id", "") or "").strip()
if session_id and not parent_session_id:
parent_task_by_session[session_id] = task_id
runtime_parent_session_id = parent_session_id or session_id
if runtime_parent_session_id:
tasks_by_parent_session.setdefault(runtime_parent_session_id, []).append(task)
checkpoints_by_session: dict[str, Any] = {}
checkpoints: list[Any] = []
getter = getattr(store, "get_execution_checkpoints", None)
if not callable(getter):
getter = getattr(store, "get_pending_checkpoints", None)
@@ -2626,29 +2610,25 @@ async def _build_company_runtime_control_by_task(
try:
kwargs = {
"project_id": project_id,
"checkpoint_types": ["company_runtime_suspended", "company_runtime_interrupted"],
"checkpoint_types": sorted(COMPANY_RUNTIME_CHECKPOINT_TYPES),
}
if getattr(getter, "__name__", "") == "get_execution_checkpoints":
kwargs["statuses"] = ["pending", "resuming"]
kwargs["statuses"] = sorted(ACTIVE_COMPANY_RUNTIME_CHECKPOINT_STATUSES)
checkpoints = await getter(**kwargs)
for checkpoint in checkpoints:
sid = str(getattr(checkpoint, "session_id", "") or "").strip()
if sid and sid not in checkpoints_by_session:
checkpoints_by_session[sid] = checkpoint
except Exception:
logger.opt(exception=True).debug("snapshot: failed to load company runtime checkpoints")
checkpoints = []
identity_index = build_company_runtime_identity_index(tasks, checkpoints)
result: dict[str, dict[str, Any]] = {}
for parent_session_id, group in tasks_by_parent_session.items():
checkpoint = checkpoints_by_session.get(parent_session_id)
parent_task_id = parent_task_by_session.get(parent_session_id, "")
if not parent_task_id:
for task in group:
if not str(getattr(task, "parent_session_id", "") or "").strip():
parent_task_id = str(getattr(task, "id", "") or "").strip()
break
if not parent_task_id and group:
parent_task_id = str(getattr(group[0], "id", "") or "").strip()
for identity in identity_index.identities:
group = [
task
for task_id in identity.runtime_task_ids
if (task := identity_index.task(task_id)) is not None
]
checkpoint = identity.checkpoint
def _task_status_value(task: Any) -> str:
status = getattr(task, "status", "")
@@ -2660,10 +2640,19 @@ async def _build_company_runtime_control_by_task(
task for task in group
if _task_status_value(task) not in {"done", "failed", "cancelled"}
]
has_running_task = any(
_task_status_value(task) == "running"
for task in non_terminal_group
)
# Persisted RUNNING is only a projection. The controller-local
# execution registry is the sole proof that this process still owns a
# coroutine capable of monitoring and persisting the run.
runtime_is_live = getattr(engine, "_task_runtime_is_live", None)
has_running_task = False
if callable(runtime_is_live):
for task in non_terminal_group:
live_result = runtime_is_live(task)
if inspect.isawaitable(live_result):
live_result = await live_result
if live_result is True:
has_running_task = True
break
any_stop_in_progress = any(
str((getattr(task, "metadata", {}) or {}).get("company_runtime_stop_state", "") or "").strip()
in {"suspending", "suspended", "resuming_after_suspending"}
@@ -2708,8 +2697,7 @@ async def _build_company_runtime_control_by_task(
"runtime_control_state": state,
"can_stop": state == "running",
"can_resume": state == "suspended",
"resume_parent_task_id": parent_task_id,
"resume_parent_session_id": parent_session_id,
"resume_parent_session_id": identity.runtime_session_id,
"pending_runtime_checkpoint_id": pending_checkpoint_id,
"stop_intent_id": str(checkpoint_payload.get("stop_intent_id", "") or ""),
}
@@ -3014,9 +3002,8 @@ async def build_project_index_sync(
session_tasks,
task_meta_map=task_meta_map,
)
shared_identity_tasks_by_session_id = _shared_role_identity_tasks_by_session_id(
company_config_tasks_by_session_id = _company_config_source_tasks_by_session_id(
session_tasks,
task_meta_map=task_meta_map,
)
child_tasks_by_parent: dict[str, list[Any]] = {}
for task in session_tasks:
@@ -3087,11 +3074,12 @@ async def build_project_index_sync(
representative_task = primary_tasks_by_session_id.get(session_id)
representative_task_id = str(getattr(representative_task, "id", "") or "").strip()
shared_session_id = _shared_role_session_key(t, t_meta)
if shared_session_id and representative_task_id and representative_task_id != task_id:
continue
if shared_session_id:
if not representative_task_id or representative_task_id != task_id:
continue
identity_task = t
identity_meta = t_meta
shared_identity_task = shared_identity_tasks_by_session_id.get(session_id)
shared_identity_task = company_config_tasks_by_session_id.get(session_id)
shared_identity_task_id = str(getattr(shared_identity_task, "id", "") or "").strip()
if shared_identity_task_id and shared_identity_task_id != task_id:
identity_task = shared_identity_task
@@ -3569,9 +3557,8 @@ async def build_collab_sync(
session_tasks,
task_meta_map=task_meta_map,
)
shared_identity_tasks_by_session_id = _shared_role_identity_tasks_by_session_id(
company_config_tasks_by_session_id = _company_config_source_tasks_by_session_id(
session_tasks,
task_meta_map=task_meta_map,
)
child_tasks_by_parent: dict[str, list[Any]] = {}
for task in session_tasks:
@@ -3616,11 +3603,15 @@ async def build_collab_sync(
representative_task = primary_tasks_by_session_id.get(session_id)
representative_task_id = str(getattr(representative_task, "id", "") or "").strip()
shared_session_id = _shared_role_session_key(t, t_meta)
if shared_session_id and representative_task_id and representative_task_id != str(getattr(t, "id", "") or "").strip():
continue
if shared_session_id:
if (
not representative_task_id
or representative_task_id != str(getattr(t, "id", "") or "").strip()
):
continue
identity_task = t
identity_meta = t_meta
shared_identity_task = shared_identity_tasks_by_session_id.get(session_id)
shared_identity_task = company_config_tasks_by_session_id.get(session_id)
shared_identity_task_id = str(getattr(shared_identity_task, "id", "") or "").strip()
if shared_identity_task_id and shared_identity_task_id != str(getattr(t, "id", "") or "").strip():
identity_task = shared_identity_task
@@ -51,8 +51,11 @@ class CompanyKanbanProjectionTests(unittest.IsolatedAsyncioTestCase):
},
)
store = MagicMock()
store.get_pending_checkpoints = AsyncMock(return_value=[])
engine = SimpleNamespace(store=store)
store.get_execution_checkpoints = AsyncMock(return_value=[])
engine = SimpleNamespace(
store=store,
_task_runtime_is_live=AsyncMock(return_value=True),
)
control = await _build_company_runtime_control_by_task(
engine,
@@ -95,8 +98,11 @@ class CompanyKanbanProjectionTests(unittest.IsolatedAsyncioTestCase):
},
)
store = MagicMock()
store.get_pending_checkpoints = AsyncMock(return_value=[])
engine = SimpleNamespace(store=store)
store.get_execution_checkpoints = AsyncMock(return_value=[])
engine = SimpleNamespace(
store=store,
_task_runtime_is_live=AsyncMock(return_value=True),
)
control = await _build_company_runtime_control_by_task(
engine,
@@ -106,8 +112,9 @@ class CompanyKanbanProjectionTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(control["child-task"]["runtime_control_state"], "running")
self.assertTrue(control["child-task"]["can_stop"])
engine._task_runtime_is_live.assert_awaited()
async def test_runtime_control_running_status_does_not_require_live_heartbeat(self) -> None:
async def test_runtime_control_running_status_requires_controller_registry_ownership(self) -> None:
created_at = datetime.now(timezone.utc)
parent_task = SimpleNamespace(
id="parent-task",
@@ -122,7 +129,7 @@ class CompanyKanbanProjectionTests(unittest.IsolatedAsyncioTestCase):
},
)
store = MagicMock()
store.get_pending_checkpoints = AsyncMock(return_value=[])
store.get_execution_checkpoints = AsyncMock(return_value=[])
engine = SimpleNamespace(
store=store,
_task_runtime_is_live=AsyncMock(return_value=False),
@@ -134,9 +141,9 @@ class CompanyKanbanProjectionTests(unittest.IsolatedAsyncioTestCase):
"proj1",
)
self.assertEqual(control["parent-task"]["runtime_control_state"], "running")
self.assertTrue(control["parent-task"]["can_stop"])
engine._task_runtime_is_live.assert_not_awaited()
self.assertEqual(control["parent-task"]["runtime_control_state"], "idle")
self.assertFalse(control["parent-task"]["can_stop"])
engine._task_runtime_is_live.assert_awaited_once_with(parent_task)
async def test_runtime_control_treats_dispatch_hold_as_suspending_without_checkpoint(self) -> None:
created_at = datetime.now(timezone.utc)
@@ -1482,7 +1489,7 @@ class CollabSyncCompanyModeTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(session["company_profile"], "custom")
self.assertEqual(session["preferred_agent"], "codex")
async def test_build_collab_sync_deduplicates_shared_role_sessions(self) -> None:
async def test_build_collab_sync_does_not_promote_shared_role_session_without_ui_anchor(self) -> None:
created_at = datetime.now(timezone.utc)
shared_session_id = "root-session:role:cto"
pending_task = SimpleNamespace(
@@ -1519,6 +1526,7 @@ class CollabSyncCompanyModeTests(unittest.IsolatedAsyncioTestCase):
engine = MagicMock()
engine.store = MagicMock()
engine.store.get_tasks = AsyncMock(return_value=[pending_task, running_task])
engine.store.get_execution_checkpoints = AsyncMock(return_value=[])
engine.project_id = "proj-shared"
engine.llm = None
@@ -1586,10 +1594,7 @@ class CollabSyncCompanyModeTests(unittest.IsolatedAsyncioTestCase):
)
sessions = result.get("sessions", [])
self.assertEqual(len(sessions), 1)
self.assertEqual(sessions[0]["project_id"], "proj-shared")
self.assertEqual(sessions[0]["task_id"], "task-running")
self.assertEqual(sessions[0]["session_id"], shared_session_id)
self.assertEqual(sessions, [])
async def test_build_collab_sync_keeps_root_session_visible_when_final_decider_shares_session(self) -> None:
created_at = datetime.now(timezone.utc)
File diff suppressed because it is too large Load Diff