|
|
@@ -1,7 +1,6 @@
|
|
|
import type { PluginInput } from '@opencode-ai/plugin';
|
|
|
import {
|
|
|
BackgroundJobBoard,
|
|
|
- type BackgroundJobRecord,
|
|
|
type BackgroundJobStore,
|
|
|
createInternalAgentTextPart,
|
|
|
deriveTaskSessionLabel,
|
|
|
@@ -12,29 +11,26 @@ import {
|
|
|
} from '../../utils';
|
|
|
import { isRecord as isObjectRecord } from '../../utils/guards';
|
|
|
import { log } from '../../utils/logger';
|
|
|
-import {
|
|
|
- appendTrailingVolatileMessage,
|
|
|
- stripTaggedContent,
|
|
|
-} from '../cache-safe-injection';
|
|
|
import { isFailoverError } from '../foreground-fallback/index';
|
|
|
import type { SessionLifecycle } from '../session-lifecycle';
|
|
|
+import { isUserMessageWithParts } from '../types';
|
|
|
import {
|
|
|
- isUserMessageWithParts,
|
|
|
- type MessagePart,
|
|
|
- type MessageWithParts,
|
|
|
-} from '../types';
|
|
|
+ BACKGROUND_JOB_BOARD_METADATA_KEY,
|
|
|
+ type InjectionState,
|
|
|
+ injectBackgroundJobBoard,
|
|
|
+ isMissingRememberedSessionError,
|
|
|
+ MAX_PROCESSED_INJECTED_COMPLETIONS,
|
|
|
+ reconcileInjectedTerminalJobs,
|
|
|
+ updateFromInjectedCompletion,
|
|
|
+} from './board-injection';
|
|
|
import { createContinuationTokenManager } from './continuation-token-manager';
|
|
|
import { createIdleReconciler } from './idle-reconciliation';
|
|
|
import { createInputWaitTracker } from './input-wait-tracker';
|
|
|
import type { PendingTaskCall } from './pending-call-tracker';
|
|
|
import { createPendingCallTracker } from './pending-call-tracker';
|
|
|
import {
|
|
|
- extractTaskSummary,
|
|
|
- formatCancelledTaskStatusOutput,
|
|
|
isActiveStatus,
|
|
|
- isLateCancelledTaskError,
|
|
|
normalizeLateCancelledTaskOutput,
|
|
|
- updateBackgroundJobFromOutput,
|
|
|
} from './status-utils';
|
|
|
import {
|
|
|
createTaskContextTracker,
|
|
|
@@ -48,11 +44,8 @@ interface TaskArgs {
|
|
|
task_id?: unknown;
|
|
|
}
|
|
|
|
|
|
-export const BACKGROUND_JOB_BOARD_METADATA_KEY =
|
|
|
- 'oh-my-opencode-slim.backgroundJobBoard';
|
|
|
-const BACKGROUND_COMPLETION_COMPLETED = /^Background task completed: /;
|
|
|
-const BACKGROUND_COMPLETION_FAILED = /^Background task failed: /;
|
|
|
-const MAX_PROCESSED_INJECTED_COMPLETIONS = 500;
|
|
|
+export { BACKGROUND_JOB_BOARD_METADATA_KEY } from './board-injection';
|
|
|
+
|
|
|
const RAW_SESSION_ID_PATTERN = /^ses_[A-Za-z0-9_-]+$/;
|
|
|
|
|
|
/**
|
|
|
@@ -66,40 +59,6 @@ const IDLE_RECONCILE_DELAY_MS = 2_000;
|
|
|
|
|
|
const CONTINUATION_NUDGE =
|
|
|
'Continue coordinating the remaining incomplete todos. Do not finalize while work remains.';
|
|
|
-function djb2Hash(str: string): string {
|
|
|
- let hash = 5381;
|
|
|
- for (let i = 0; i < str.length; i++) {
|
|
|
- hash = (hash << 5) + hash + str.charCodeAt(i);
|
|
|
- }
|
|
|
- return (hash >>> 0).toString(16).padStart(8, '0');
|
|
|
-}
|
|
|
-
|
|
|
-function createOccurrenceId(
|
|
|
- part: MessagePart,
|
|
|
- message: MessageWithParts,
|
|
|
- partIndex: number,
|
|
|
-): string {
|
|
|
- if (typeof part.id === 'string') {
|
|
|
- return part.id;
|
|
|
- }
|
|
|
-
|
|
|
- if (typeof message.info.id === 'string') {
|
|
|
- return `${message.info.id}:${partIndex}`;
|
|
|
- }
|
|
|
-
|
|
|
- const sessionID = message.info.sessionID ?? 'unknown';
|
|
|
- const content = typeof part.text === 'string' ? part.text : '';
|
|
|
-
|
|
|
- const status = parseTaskStatusOutput(content);
|
|
|
- if (status) {
|
|
|
- const stableKey = `${sessionID}:${status.taskID}:${status.state}:${status.result ?? ''}`;
|
|
|
- const hash = djb2Hash(stableKey);
|
|
|
- return `anon:${hash}`;
|
|
|
- }
|
|
|
-
|
|
|
- const hash = djb2Hash(`${sessionID}:${content}`);
|
|
|
- return `anon:${hash}`;
|
|
|
-}
|
|
|
|
|
|
export function createTaskSessionManagerHook(
|
|
|
_ctx: PluginInput,
|
|
|
@@ -158,7 +117,8 @@ export function createTaskSessionManagerHook(
|
|
|
const idleReconciler = createIdleReconciler({
|
|
|
backgroundJobBoard,
|
|
|
evaluateContinuation: (s, t) => evaluateContinuation(s, t),
|
|
|
- reconcileInjectedTerminalJobs,
|
|
|
+ reconcileInjectedTerminalJobs: (parentSessionID: string) =>
|
|
|
+ reconcileInjectedTerminalJobs(injectionState, parentSessionID),
|
|
|
idleReconcileDelayMs:
|
|
|
options.idleReconcileDelayMs ?? IDLE_RECONCILE_DELAY_MS,
|
|
|
isFallbackInProgress: options.isFallbackInProgress,
|
|
|
@@ -371,192 +331,16 @@ export function createTaskSessionManagerHook(
|
|
|
});
|
|
|
}
|
|
|
|
|
|
- function updateFromInjectedCompletion(
|
|
|
- part: MessagePart,
|
|
|
- message: MessageWithParts,
|
|
|
- _messageIndex: number,
|
|
|
- partIndex: number,
|
|
|
- ): BackgroundJobRecord | undefined {
|
|
|
- if (part.type !== 'text' || typeof part.text !== 'string') {
|
|
|
- return undefined;
|
|
|
- }
|
|
|
-
|
|
|
- if (part.synthetic !== true) return undefined;
|
|
|
-
|
|
|
- const status = parseTaskStatusOutput(part.text);
|
|
|
- if (!status) {
|
|
|
- log('[task-session-manager] synthetic part missing task status', {
|
|
|
- textPreview: part.text.slice(0, 120),
|
|
|
- });
|
|
|
- return undefined;
|
|
|
- }
|
|
|
- if (status.state !== 'completed' && status.state !== 'error') {
|
|
|
- return undefined;
|
|
|
- }
|
|
|
-
|
|
|
- const summary = extractTaskSummary(part.text);
|
|
|
- const isCompleted = summary
|
|
|
- ? BACKGROUND_COMPLETION_COMPLETED.test(summary)
|
|
|
- : status.state === 'completed';
|
|
|
- const isFailed = summary
|
|
|
- ? BACKGROUND_COMPLETION_FAILED.test(summary)
|
|
|
- : status.state === 'error';
|
|
|
- if (summary && !isCompleted && !isFailed) return undefined;
|
|
|
-
|
|
|
- const occurrenceId = createOccurrenceId(part, message, partIndex);
|
|
|
-
|
|
|
- const existing = backgroundJobBoard.get(status.taskID);
|
|
|
- if (isFailed && isLateCancelledTaskError(existing, status.state)) {
|
|
|
- part.text = formatCancelledTaskStatusOutput(
|
|
|
- status.taskID,
|
|
|
- backgroundJobBoard.getResultSummary(status.taskID),
|
|
|
- );
|
|
|
- log('[task-session-manager] normalized late cancelled injected failure', {
|
|
|
- taskID: status.taskID,
|
|
|
- alias: existing?.alias,
|
|
|
- parsedState: status.state,
|
|
|
- boardState: existing?.state,
|
|
|
- terminalState: existing?.terminalState,
|
|
|
- result: status.result,
|
|
|
- });
|
|
|
- rememberProcessedInjectedCompletion(occurrenceId);
|
|
|
- return existing;
|
|
|
- }
|
|
|
-
|
|
|
- if (isCompleted && status.state !== 'completed') return undefined;
|
|
|
- if (isFailed && status.state !== 'error') return undefined;
|
|
|
-
|
|
|
- if (processedInjectedCompletions.has(occurrenceId)) return undefined;
|
|
|
-
|
|
|
- const updated = updateBackgroundJobFromOutput(
|
|
|
- part.text,
|
|
|
- backgroundJobBoard,
|
|
|
- taskContextTracker,
|
|
|
- );
|
|
|
- if (!updated) return undefined;
|
|
|
-
|
|
|
- log('[task-session-manager] processed injected background completion', {
|
|
|
- taskID: updated.taskID,
|
|
|
- alias: updated.alias,
|
|
|
- parentSessionID: updated.parentSessionID,
|
|
|
- state: updated.state,
|
|
|
- occurrenceId,
|
|
|
- });
|
|
|
-
|
|
|
- rememberProcessedInjectedCompletion(occurrenceId);
|
|
|
- return updated;
|
|
|
- }
|
|
|
-
|
|
|
- function rememberProcessedInjectedCompletion(signature: string): void {
|
|
|
- processedInjectedCompletions.add(signature);
|
|
|
- processedInjectedCompletionOrder.push(signature);
|
|
|
-
|
|
|
- while (
|
|
|
- processedInjectedCompletionOrder.length >
|
|
|
- MAX_PROCESSED_INJECTED_COMPLETIONS
|
|
|
- ) {
|
|
|
- const evicted = processedInjectedCompletionOrder.shift();
|
|
|
- if (!evicted) break;
|
|
|
- processedInjectedCompletions.delete(evicted);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- function isMissingRememberedSessionError(output: string): boolean {
|
|
|
- const firstLine = output.split(/\r?\n/, 1)[0]?.trim().toLowerCase() ?? '';
|
|
|
- return (
|
|
|
- firstLine.startsWith('[error]') &&
|
|
|
- firstLine.includes('session') &&
|
|
|
- (firstLine.includes('not found') || firstLine.includes('no session'))
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
- function rememberInjectedTerminalJobs(parentSessionID: string): void {
|
|
|
- const taskIDs = backgroundJobBoard
|
|
|
- .list(parentSessionID)
|
|
|
- .filter((job) => job.terminalUnreconciled)
|
|
|
- .map((job) => job.taskID);
|
|
|
- if (taskIDs.length === 0) return;
|
|
|
-
|
|
|
- log('[task-session-manager] terminal jobs injected for reconciliation', {
|
|
|
- parentSessionID,
|
|
|
- taskIDs,
|
|
|
- });
|
|
|
-
|
|
|
- const existing =
|
|
|
- terminalJobsInjectedByParent.get(parentSessionID) ?? new Set<string>();
|
|
|
- for (const taskID of taskIDs) {
|
|
|
- existing.add(taskID);
|
|
|
- }
|
|
|
- terminalJobsInjectedByParent.set(parentSessionID, existing);
|
|
|
- }
|
|
|
-
|
|
|
- function reconcileInjectedTerminalJobs(parentSessionID: string): void {
|
|
|
- const taskIDs = terminalJobsInjectedByParent.get(parentSessionID);
|
|
|
- if (!taskIDs) return;
|
|
|
-
|
|
|
- log('[task-session-manager] reconciling injected terminal jobs', {
|
|
|
- parentSessionID,
|
|
|
- taskIDs: [...taskIDs],
|
|
|
- });
|
|
|
-
|
|
|
- for (const taskID of taskIDs) {
|
|
|
- backgroundJobBoard.markReconciled(taskID);
|
|
|
- }
|
|
|
- terminalJobsInjectedByParent.delete(parentSessionID);
|
|
|
- }
|
|
|
-
|
|
|
- async function injectBackgroundJobBoard(
|
|
|
- _input: Record<string, never>,
|
|
|
- output: { messages?: unknown },
|
|
|
- ): Promise<void> {
|
|
|
- const messages = Array.isArray(output.messages) ? output.messages : [];
|
|
|
-
|
|
|
- // Strip previously injected board content: parts attached to real
|
|
|
- // messages (legacy placement) and whole synthetic board messages.
|
|
|
- stripTaggedContent(messages, BACKGROUND_JOB_BOARD_METADATA_KEY);
|
|
|
-
|
|
|
- for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
|
- const message = messages[i];
|
|
|
- if (!isUserMessageWithParts(message)) continue;
|
|
|
- if (message.info.agent && message.info.agent !== 'orchestrator') return;
|
|
|
- if (
|
|
|
- !message.info.sessionID ||
|
|
|
- !options.shouldManageSession(message.info.sessionID)
|
|
|
- ) {
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- const reminder = backgroundJobBoard.formatForPrompt(
|
|
|
- message.info.sessionID,
|
|
|
- );
|
|
|
- if (!reminder) return;
|
|
|
-
|
|
|
- const textPart = message.parts.find(
|
|
|
- (part) => part.type === 'text' && typeof part.text === 'string',
|
|
|
- );
|
|
|
- if (!textPart || isInternalInitiatorPart(textPart)) return;
|
|
|
-
|
|
|
- rememberInjectedTerminalJobs(message.info.sessionID);
|
|
|
- // Append the board as its own trailing message rather than mutating
|
|
|
- // an existing user message. In long tool loops the latest user
|
|
|
- // message becomes deep history; rewriting it on board state changes
|
|
|
- // would invalidate the provider prompt cache for everything after
|
|
|
- // it. A trailing message keeps board churn at the end of the
|
|
|
- // prompt, where it only costs itself.
|
|
|
- appendTrailingVolatileMessage(
|
|
|
- messages,
|
|
|
- {
|
|
|
- ...message.info,
|
|
|
- id: `${message.info.id}-background-job-board`,
|
|
|
- },
|
|
|
- {
|
|
|
- text: reminder,
|
|
|
- metadataKey: BACKGROUND_JOB_BOARD_METADATA_KEY,
|
|
|
- },
|
|
|
- );
|
|
|
- return;
|
|
|
- }
|
|
|
- }
|
|
|
+ const injectionState: InjectionState = {
|
|
|
+ backgroundJobBoard,
|
|
|
+ processedInjectedCompletions,
|
|
|
+ processedInjectedCompletionOrder,
|
|
|
+ terminalJobsInjectedByParent,
|
|
|
+ maxProcessedInjectedCompletions: MAX_PROCESSED_INJECTED_COMPLETIONS,
|
|
|
+ metadataKey: BACKGROUND_JOB_BOARD_METADATA_KEY,
|
|
|
+ shouldManageSession: options.shouldManageSession,
|
|
|
+ taskContextTracker,
|
|
|
+ };
|
|
|
|
|
|
return {
|
|
|
observeChatMessage: (input: unknown, output: unknown): void => {
|
|
|
@@ -841,12 +625,21 @@ export function createTaskSessionManagerHook(
|
|
|
}
|
|
|
|
|
|
for (const [partIndex, part] of message.parts.entries()) {
|
|
|
- updateFromInjectedCompletion(part, message, messageIndex, partIndex);
|
|
|
+ updateFromInjectedCompletion(
|
|
|
+ injectionState,
|
|
|
+ part,
|
|
|
+ message,
|
|
|
+ messageIndex,
|
|
|
+ partIndex,
|
|
|
+ );
|
|
|
}
|
|
|
}
|
|
|
},
|
|
|
|
|
|
- injectBackgroundJobBoard,
|
|
|
+ injectBackgroundJobBoard: (
|
|
|
+ input: Record<string, never>,
|
|
|
+ output: { messages?: unknown },
|
|
|
+ ) => injectBackgroundJobBoard(injectionState, input, output),
|
|
|
|
|
|
event: async (input: {
|
|
|
event: {
|