Skip to content

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import EventEmitter from 'node:events';
import { randomUUID } from 'node:crypto';
import { rmSync, writeFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
Expand Down Expand Up @@ -130,6 +132,7 @@ interface OpenCodeServerHarnessOptions {
turnStallTimeoutMs?: number;
subagentSettlementGraceMs?: number;
visualProofTimeoutMs?: number;
visualProofAttemptStatePath?: string;
queuedPromptRetryDelayMs?: number;
/**
* Max automatic continue attempts after a provider rate-limit session.error
Expand Down Expand Up @@ -334,8 +337,15 @@ const FALLBACK_OPENCODE_STOP_HOOK_REMINDER =
'Before finalizing, post a terminal chat-visible reply for the current turn.';
const ROOMOTE_OPENCODE_VISUAL_AGENT_NAME = 'visual';
const CAPTURE_VISUAL_PROOF_SKILL = 'capture-visual-proof';
const VISUAL_PROOF_TIMEOUT_RECOVERY_PROMPT =
'The visual proof step exceeded its shared five-minute deadline. Do not retry capture or run further proof recovery. Return a blocked proof handoff with blocker type `proof capture timed out`, then continue the active parent workflow without visual proof.';
const VISUAL_PROOF_ATTEMPT_STATE_PATH =
'/tmp/roomote-visual-proof-attempt.json';
const formatVisualProofTimeoutRecoveryPrompt = (attemptId: string | null) => {
const attemptPath = attemptId
? `tmp/capture-visual-proof/${attemptId}/`
: 'unavailable';

return `The visual proof step exceeded its shared five-minute deadline. Do not retry capture or run further proof recovery. Before reporting the outcome, list this task's \`visual-proof\` artifacts once. The interrupted proof attempt ID is ${attemptId ?? 'unavailable'} and its exact artifact path prefix is \`${attemptPath}\`. Carry only matching artifacts into delivery instead of reporting a timeout. If the attempt ID is unavailable or no matching uploads exist, return a blocked proof handoff with blocker type \`proof capture timed out\`, then continue the active parent workflow without visual proof. Never reuse artifacts from another proof attempt.`;
};
// OpenCode's built-in tool for loading skills into the session.
const OPENCODE_SKILL_TOOL = 'skill';
// Hidden continuation submitted automatically after a turn that exited plan
Expand Down Expand Up @@ -1605,6 +1615,7 @@ export class OpenCodeServerHarness
private readonly stopHookReminderStallTimeoutMs: number;
private readonly subagentSettlementGraceMs: number;
private readonly visualProofTimeoutMs: number;
private readonly visualProofAttemptStatePath: string;
private readonly queuedPromptRetryDelayMs: number;
private readonly providerRateLimitMaxRetries: number;
private readonly providerRateLimitBaseDelayMs: number;
Expand Down Expand Up @@ -1690,6 +1701,7 @@ export class OpenCodeServerHarness
// turns can run on the built-in read-only `plan` agent.
private activeWorkflowSkill: string | null = null;
private visualProofTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
private visualProofAttemptId: string | null = null;
private commandEnv: Record<string, string> | undefined;
private stopHookReminderCount = 0;
private terminalChatReplyDeliveryFailed = false;
Expand Down Expand Up @@ -1757,6 +1769,8 @@ export class OpenCodeServerHarness
options.subagentSettlementGraceMs ?? DEFAULT_SUBAGENT_SETTLEMENT_GRACE_MS;
this.visualProofTimeoutMs =
options.visualProofTimeoutMs ?? DEFAULT_VISUAL_PROOF_TIMEOUT_MS;
this.visualProofAttemptStatePath =
options.visualProofAttemptStatePath ?? VISUAL_PROOF_ATTEMPT_STATE_PATH;
this.queuedPromptRetryDelayMs =
options.queuedPromptRetryDelayMs ?? DEFAULT_QUEUED_PROMPT_RETRY_DELAY_MS;
this.providerRateLimitMaxRetries =
Expand Down Expand Up @@ -1958,6 +1972,7 @@ export class OpenCodeServerHarness
this.clearQueuedPromptRetryTimer();
this.clearProviderErrorRecoveryState();
this.clearVisualProofTimeout();
this.clearVisualProofAttemptState();
this.clearAllExecuteToolProgress();
void this.cleanupVisualAttachmentDirectories();
this.rejectEventStreamReady?.(
Expand Down Expand Up @@ -2155,6 +2170,7 @@ export class OpenCodeServerHarness
this.currentWorkflowPhase = null;
this.activeWorkflowSkill = null;
this.clearVisualProofTimeout();
this.clearVisualProofAttemptState();
Comment thread
roomote-roomote[bot] marked this conversation as resolved.
this.inFlight = false;
this.prompts.clear();
this.clearQueuedPromptRetryTimer();
Expand Down Expand Up @@ -2216,6 +2232,7 @@ export class OpenCodeServerHarness
this.currentWorkflowPhase = command.data.workflowPhase ?? null;
this.activeWorkflowSkill = null;
this.clearVisualProofTimeout();
this.clearVisualProofAttemptState();
this.cancelRequestedBeforeSession = false;
this.resetSessionCreateAbortController();

Expand Down Expand Up @@ -2379,6 +2396,7 @@ export class OpenCodeServerHarness

private async handleCancelTask(command?: CancelTaskCommand): Promise<void> {
this.clearVisualProofTimeout();
this.clearVisualProofAttemptState();
const sessionId = this.sessionId;

if (!sessionId) {
Expand Down Expand Up @@ -3712,6 +3730,25 @@ export class OpenCodeServerHarness
return;
}

this.visualProofAttemptId = randomUUID();
try {
writeFileSync(
Comment thread
roomote-roomote[bot] marked this conversation as resolved.
this.visualProofAttemptStatePath,
`${JSON.stringify({
attemptId: this.visualProofAttemptId,
startedAt: new Date().toISOString(),
})}\n`,
{ encoding: 'utf8', mode: 0o600 },
);
} catch (error) {
this.logger.warn(
`Failed to persist visual proof attempt state: ${
error instanceof Error ? error.message : String(error)
}`,
);
this.visualProofAttemptId = null;
}

const timer = setTimeout(() => {
this.visualProofTimeoutTimer = null;
void this.recoverVisualProofTimeout();
Expand All @@ -3729,6 +3766,11 @@ export class OpenCodeServerHarness
this.visualProofTimeoutTimer = null;
}

private clearVisualProofAttemptState(): void {
this.visualProofAttemptId = null;
rmSync(this.visualProofAttemptStatePath, { force: true });
}

private async recoverVisualProofTimeout(): Promise<void> {
if (
this.disposed ||
Expand All @@ -3744,7 +3786,7 @@ export class OpenCodeServerHarness
// The replay is a parent-workflow continuation, not a new proof attempt.
this.activeWorkflowSkill = null;
const queuedId = this.prompts.enqueue({
text: VISUAL_PROOF_TIMEOUT_RECOVERY_PROMPT,
text: formatVisualProofTimeoutRecoveryPrompt(this.visualProofAttemptId),
visibleInTranscript: false,
});
this.prompts.prioritize(queuedId);
Expand Down Expand Up @@ -4248,6 +4290,7 @@ export class OpenCodeServerHarness
this.pendingUserInputRequests.clear();
this.nativeQuestionRequestIds.clear();
this.clearAllExecuteToolProgress();
this.clearVisualProofAttemptState();
this.runtimeEvents.taskAborted(sessionId);
}

Expand Down Expand Up @@ -5282,6 +5325,7 @@ export class OpenCodeServerHarness
this.inFlight = false;
this.finalizedAssistantTurn = null;
this.clearAllExecuteToolProgress();
this.clearVisualProofAttemptState();

if (this.prompts.hasQueuedMessages()) {
await this.drainQueuedPrompts();
Expand Down
Loading
Loading