Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/worker/src/sandbox-server/lib/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ export interface QueuedPromptMessageSnapshot {
userName?: string;
userImageUrl?: string;
clientMessageId?: string;
/**
* Set on prompts the harness queues on its own (recovery and continuation
* nudges), so a reader of the transcript can tell them from a request a
* person or the platform sent.
*/
source?: string;
timestamp: number;
}

Expand Down

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
Expand Up @@ -3900,6 +3900,7 @@ export class OpenCodeServerHarness
const queuedId = this.prompts.enqueue({
text: VISUAL_PROOF_TIMEOUT_RECOVERY_PROMPT,
visibleInTranscript: false,
source: 'opencode-visual-proof-recovery',
});
this.prompts.prioritize(queuedId);

Expand Down Expand Up @@ -3936,6 +3937,7 @@ export class OpenCodeServerHarness
this.prompts.enqueue({
text: PLAN_EXIT_CONTINUATION_PROMPT,
visibleInTranscript: false,
source: 'opencode-plan-exit-continuation',
});
}

Expand Down Expand Up @@ -4521,6 +4523,7 @@ export class OpenCodeServerHarness
const queuedId = this.prompts.enqueue({
text: recovery.promptText,
visibleInTranscript: false,
source: 'opencode-provider-error-recovery',
});
this.prompts.prioritize(queuedId);
this.providerErrorRecoveryQueuedPromptId = queuedId;
Expand Down Expand Up @@ -4582,6 +4585,7 @@ export class OpenCodeServerHarness
const queuedId = this.prompts.enqueue({
text: OPENCODE_RATE_LIMIT_RETRY_PROMPT_TEXT,
visibleInTranscript: false,
source: 'opencode-rate-limit-retry',
});
this.prompts.prioritize(queuedId);

Expand Down Expand Up @@ -6041,6 +6045,7 @@ export class OpenCodeServerHarness
userName: next.userName,
userImageUrl: next.userImageUrl,
clientMessageId: next.clientMessageId,
source: next.source,
});
await this.submitPrompt({
text: next.text,
Expand All @@ -6051,6 +6056,7 @@ export class OpenCodeServerHarness
userName: next.userName,
userImageUrl: next.userImageUrl,
clientMessageId: next.clientMessageId,
source: next.source,
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export class RuntimePromptQueue {
userName?: string;
userImageUrl?: string;
clientMessageId?: string;
source?: string;
}): string {
// Hidden platform follow-ups reuse one clientMessageId per logical
// notification (e.g. the PR re-review prompt for a run), so a newer
Expand All @@ -75,6 +76,7 @@ export class RuntimePromptQueue {
userName: prompt.userName,
userImageUrl: prompt.userImageUrl,
clientMessageId: prompt.clientMessageId,
source: prompt.source,
timestamp: Date.now(),
};

Expand Down Expand Up @@ -247,6 +249,7 @@ export class RuntimePromptQueue {
...(message.clientMessageId
? { clientMessageId: message.clientMessageId }
: {}),
...(message.source ? { source: message.source } : {}),
timestamp: message.timestamp,
}));
this.queuedMessageIdCounter = this.queuedMessages.reduce(
Expand Down

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

65 changes: 55 additions & 10 deletions packages/cloud-agents/src/server/task-completion-gate.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { redactBrainText } from '@roomote/communication/redact-brain-text';
import { and, asc, db, desc, eq, sql, taskMessages } from '@roomote/db/server';
import { and, asc, db, desc, eq, taskMessages } from '@roomote/db/server';
import {
ACP_ENVELOPE_EVENT_TYPES,
extractAcpMessageText,
Expand Down Expand Up @@ -144,7 +144,42 @@ function clip(text: string, maxChars: number): string {
* What the person asked this task for: the opening prompt and the latest
* follow-ups. Read from the transcript rather than taken from the sandbox so
* the agent cannot restate its own request.
*
* The opening prompt counts whether or not it is visible: a task delegated
* from a Session gets its request as a hidden `<request>` prompt. Later
* hidden prompts never count. Those are the platform's and the harness's own
* notices (an environment-setup notice, a recovery or continuation nudge, a
* completion-check reminder), and reading one back as a follow-up would have
* the check judging its own instructions. A person's follow-up, from the web
* or a chat thread, is always visible.
*/
const HARNESS_PROMPT_SOURCE_PREFIX = 'opencode-';

type PromptRow = {
id: string;
contentBlocks: unknown;
payload: unknown;
metadata: unknown;
};

function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object'
? (value as Record<string, unknown>)
: null;
}

function isHiddenPrompt(row: PromptRow): boolean {
return asRecord(row.metadata)?.visibleInTranscript === false;
}

function isHarnessPrompt(row: PromptRow): boolean {
return [asRecord(row.payload)?.source, asRecord(row.metadata)?.source].some(
(source) =>
typeof source === 'string' &&
source.startsWith(HARNESS_PROMPT_SOURCE_PREFIX),
);
}

async function loadTaskRequests(
taskId: string,
): Promise<{ request: string; followUps: string } | null> {
Expand All @@ -156,24 +191,34 @@ async function loadTaskRequests(
id: taskMessages.id,
contentBlocks: taskMessages.contentBlocks,
payload: taskMessages.payload,
metadata: taskMessages.metadata,
})
.from(taskMessages)
.where(
and(
eq(taskMessages.taskId, taskId),
eq(taskMessages.eventType, ACP_ENVELOPE_EVENT_TYPES.UserPrompt),
sql`coalesce(${taskMessages.metadata} ->> 'visibleInTranscript', 'true') <> 'false'`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing this filter also admits the harness's hidden UserPrompt records. For example, submitPrompt() persists the completion-gate reminder with visibleInTranscript: false; on the next gate pass it becomes a follow-up request. That can make the judgment evaluate its own remediation instructions and repeatedly flag an otherwise completed task. Keep filtering those system-injected/reminder prompts while allowing the delegated opening request, and add a regression test for a hidden completion-gate reminder.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The source check does not cover the hidden continuations sent through drainQueuedPrompts(). Provider-error/rate-limit retries, visual-proof recovery, and plan-exit continuations are enqueued with visibleInTranscript: false but no source; the drain persists them as UserPrompt rows without a source. With the visibility SQL filter removed, these messages still enter followUps and can influence a later completion judgment. Preserve an origin/source for queued harness prompts (or otherwise exclude them) and add coverage for one queued hidden continuation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is still possible after a queue restore: RuntimePromptQueue.restore() rebuilds each message without copying source. A reconnect, or a shouldBlockPrompt/shouldReconnect replay, therefore drains the hidden continuation without its opencode-* marker and lets it enter completion-gate follow-ups. Preserve source in restore() and cover a restored hidden continuation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copying source through restore() fixes the reconnect path, but the predicate only excludes opencode-* sources. run-task.ts also delivers hidden environment-setup notices with source: 'environment-setup'; handleSendMessage() persists those as UserPrompt rows. They now pass isHarnessPrompt() and are included as completion-gate follow-ups, so the judge can still receive a platform notification as user work. Exclude known non-user hidden prompt sources (or retain a reliable request-origin marker) and cover this path.

),
)
.orderBy(direction(taskMessages.ts), direction(taskMessages.createdAt))
.limit(PROMPT_SCAN_LIMIT);
const visiblePrompts = (rows: Awaited<ReturnType<typeof scan>>) =>
const requestPrompts = (
rows: PromptRow[],
options: { includeHidden: boolean },
) =>
rows.flatMap((row) => {
const payload =
row.payload && typeof row.payload === 'object'
? (row.payload as Record<string, unknown>)
: null;
const raw = extractAcpMessageText(row.contentBlocks, payload)?.trim();
if (
isHarnessPrompt(row) ||
(!options.includeHidden && isHiddenPrompt(row))
) {
return [];
}

const payload = asRecord(row.payload);
const raw = extractAcpMessageText(
row.contentBlocks as Parameters<typeof extractAcpMessageText>[0],
payload,
)?.trim();
const text = raw
? normalizeTranscriptUserText(
isSystemInjectedAcpPromptText(raw)
Expand All @@ -186,13 +231,13 @@ async function loadTaskRequests(
return text ? [{ id: row.id, text }] : [];
});

const [opening] = visiblePrompts(await scan(asc));
const [opening] = requestPrompts(await scan(asc), { includeHidden: true });

if (!opening) {
return null;
}

const followUps = visiblePrompts(await scan(desc))
const followUps = requestPrompts(await scan(desc), { includeHidden: false })
.filter((prompt) => prompt.id !== opening.id)
.slice(0, FOLLOW_UP_LIMIT)
.reverse();
Expand Down
Loading