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
22 changes: 22 additions & 0 deletions .claude/rules/06-vm-agent-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,28 @@ When a remote system (VM) is responsible for triggering its own cleanup:
3. **Both paths must use the same deletion logic** — reuse `deleteServer()`, `deleteDNSRecord()`, `cleanupWorkspaceDNSRecords()`
4. **Guard against duplicate execution** — Use DB status transitions (`running` -> `stopping`) as a lock.

## High-Frequency Callback Signals

When the VM agent converts high-frequency runtime or harness lifecycle signals
into HTTP callbacks to the control plane, do not post one callback per edge.

Required pattern:

1. **Coalesce before crossing the network** — debounce bursts and send the latest
resolved state after a short quiet window.
2. **Serialize callback sends per signal source** — one source must not have
multiple in-flight POSTs that can arrive out of order and overwrite each
other.
3. **Read authoritative local state at send time** — edge handlers may record
intent, but they must not capture a status value that can become stale before
the POST lands.
4. **Dedupe only after success** — failed POST attempts must not update the
"last successfully reported" state; periodic re-report loops remain the
reliability backstop.
5. **Test cadence and ordering** — regression tests must prove bursts collapse,
turn-boundary stale reports are suppressed, and semantically new payloads
such as runtime-work lease changes still reach the control plane.

## Modifying Cloud-Init

1. Edit `packages/cloud-init/src/template.ts`
Expand Down
1 change: 1 addition & 0 deletions .claude/skills/env-reference/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ Generated deployments validate and pass these values through cloud-init to newly
- `ACP_PROMPT_RETRY_INITIAL_BACKOFF` — Initial backoff before retrying transient provider prompt errors (default: 15s)
- `ACP_PROMPT_RETRY_MAX_BACKOFF` — Max exponential backoff for transient provider prompt retries (default: 2m)
- `ACTIVITY_REREPORT_INTERVAL` — Re-send `prompting` activity while a prompt is active (default: 60s)
- `ACP_HARNESS_ACTIVITY_REPORT_DEBOUNCE` — Debounce window for coalescing high-frequency ACP harness/tool-call activity reports before POSTing to the control plane (default: 750ms)
- `ACP_CHECKPOINT_PREEMPT_GRACE` — Graceful ACP cancel/close wait before harness force-stop (default: 30s)
- `ACP_CHECKPOINT_PREEMPT_MAX_GRACE` — Maximum caller-selected checkpoint rollover grace (default: 2m)
- `ACP_CHECKPOINT_ROLLOVER_TIMEOUT` — Full checkpoint restart and strict LoadSession deadline (default: 2m)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ function Ring({ active, size }: { active: boolean; size: number }) {
export type CompletionDockCenterAction = 'interrupt' | 'sleep' | 'archive';

export interface CompletionDockProps {
/** True while the agent is producing output (agentActivity !== 'idle'). */
/** True while the agent is producing output; caller may stabilize brief idle reversals. */
working: boolean;
/** Center lifecycle action. Defaults to legacy interrupt/archive derivation for compatibility. */
centerAction?: CompletionDockCenterAction;
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/project-message-view/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -682,9 +682,9 @@ export const ProjectMessageView: FC<ProjectMessageViewProps> = ({
is only the primary action after the reversible sleep boundary. */}
{isActive &&
canWriteSession &&
(lc.agentActivity !== 'idle' || canSleepSession || canArchiveSession) && (
(lc.completionDockWorking || canSleepSession || canArchiveSession) && (
<CompletionDock
working={lc.agentActivity !== 'idle'}
working={lc.completionDockWorking}
centerAction={dockCenterAction}
hasPlan={!!planItem}
onInterrupt={lc.handleCancelPrompt}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useEffect, useRef, useState } from 'react';

import type { AgentActivityState } from './types';

export const COMPLETION_DOCK_IDLE_STABILIZE_MS = 1_000;

export function useCompletionDockWorking(agentActivity: AgentActivityState): boolean {
const activityWorking = agentActivity !== 'idle';
const [completionDockWorking, setCompletionDockWorking] = useState(activityWorking);
const completionDockWorkingRef = useRef(activityWorking);
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
if (activityWorking) {
if (idleTimerRef.current) {
clearTimeout(idleTimerRef.current);
idleTimerRef.current = null;
}
if (!completionDockWorkingRef.current) {
completionDockWorkingRef.current = true;
setCompletionDockWorking(true);
}
return;
}

if (!completionDockWorkingRef.current || idleTimerRef.current) return;

idleTimerRef.current = setTimeout(() => {
idleTimerRef.current = null;
completionDockWorkingRef.current = false;
setCompletionDockWorking(false);
}, COMPLETION_DOCK_IDLE_STABILIZE_MS);

return () => {
if (idleTimerRef.current) {
clearTimeout(idleTimerRef.current);
idleTimerRef.current = null;
}
};
}, [activityWorking]);

return activityWorking || completionDockWorking;
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
VIRTUAL_START,
} from './types';
import { useActivityVerifyTimer } from './useActivityVerifyTimer';
import { useCompletionDockWorking } from './useCompletionDockWorking';
import { useConnectionRecovery } from './useConnectionRecovery';
import { useSessionFileUpload } from './useSessionFileUpload';
import type { UseSessionLifecycleResult } from './useSessionLifecycle.types';
Expand Down Expand Up @@ -140,6 +141,7 @@ export function useSessionLifecycle(
const [followUp, setFollowUp] = useState('');
const [sendingFollowUp, setSendingFollowUp] = useState(false);
const [agentActivity, setAgentActivity] = useState<AgentActivityState>('idle');
const completionDockWorking = useCompletionDockWorking(agentActivity);
const sleepingWakePendingRef = useRef(false);
const [currentPlan, setCurrentPlan] = useState<SessionStateSnapshot['currentPlan']>(null);
const [promptStartedAt, setPromptStartedAt] = useState<number | null>(null);
Expand Down Expand Up @@ -649,7 +651,7 @@ export function useSessionLifecycle(
// Cancel the current in-flight prompt via REST API
const cancellingRef = useRef(false);
const handleCancelPrompt = useCallback(() => {
if (agentActivity === 'idle' || cancellingRef.current) return;
if (!completionDockWorking || cancellingRef.current) return;
cancellingRef.current = true;
cancelAgentPrompt(projectId, sessionId)
.then(() => {
Expand All @@ -661,7 +663,7 @@ export function useSessionLifecycle(
.finally(() => {
cancellingRef.current = false;
});
}, [agentActivity, projectId, sessionId]);
}, [completionDockWorking, projectId, sessionId]);

// Load more (pagination)
const loadMore = async () => {
Expand Down Expand Up @@ -763,6 +765,7 @@ export function useSessionLifecycle(
showConnectionBanner: recovery.showConnectionBanner,
retryWs,
agentActivity,
completionDockWorking,
/** True while a wake is in flight (hydrated from D1 or pushed over the socket). */
isWaking: wake.isWaking,
/** Current wake phase, or null before the replacement runner reports a step. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export interface UseSessionLifecycleResult {
showConnectionBanner: boolean;
retryWs: () => void;
agentActivity: AgentActivityState;
/** Debounced working signal used only by CompletionDock to swallow brief idle reversals. */
completionDockWorking: boolean;
/** True while a wake is in flight (hydrated from D1 or pushed over the socket). */
isWaking: boolean;
/** Current wake phase, or null before the replacement runner reports a step. */
Expand Down
74 changes: 74 additions & 0 deletions apps/web/tests/unit/components/useCompletionDockWorking.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import type { AgentActivityState } from '../../../src/components/project-message-view/types';
import {
COMPLETION_DOCK_IDLE_STABILIZE_MS,
useCompletionDockWorking,
} from '../../../src/components/project-message-view/useCompletionDockWorking';

describe('useCompletionDockWorking', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
});

it('promotes idle to working immediately', () => {
const { result, rerender } = renderHook(
({ activity }: { activity: AgentActivityState }) => useCompletionDockWorking(activity),
{ initialProps: { activity: 'idle' } }
);

expect(result.current).toBe(false);

rerender({ activity: 'prompting' });

expect(result.current).toBe(true);
});

it('delays working to idle transitions', async () => {
const { result, rerender } = renderHook(
({ activity }: { activity: AgentActivityState }) => useCompletionDockWorking(activity),
{ initialProps: { activity: 'responding' } }
);

expect(result.current).toBe(true);

rerender({ activity: 'idle' });

expect(result.current).toBe(true);
await act(async () => {
await vi.advanceTimersByTimeAsync(COMPLETION_DOCK_IDLE_STABILIZE_MS - 1);
});
expect(result.current).toBe(true);

await act(async () => {
await vi.advanceTimersByTimeAsync(1);
});
expect(result.current).toBe(false);
});

it('swallows an idle transition that reverses within the stabilization window', async () => {
const { result, rerender } = renderHook(
({ activity }: { activity: AgentActivityState }) => useCompletionDockWorking(activity),
{ initialProps: { activity: 'prompting' } }
);

rerender({ activity: 'idle' });
await act(async () => {
await vi.advanceTimersByTimeAsync(COMPLETION_DOCK_IDLE_STABILIZE_MS / 2);
});
expect(result.current).toBe(true);

rerender({ activity: 'responding' });
await act(async () => {
await vi.advanceTimersByTimeAsync(COMPLETION_DOCK_IDLE_STABILIZE_MS);
});

expect(result.current).toBe(true);
});
});
1 change: 1 addition & 0 deletions apps/www/src/content/docs/docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,7 @@ Webhook damping uses Cloudflare KV's eventually consistent read-update-write beh
| `ACP_PROMPT_RETRY_INITIAL_BACKOFF` | `15s` | Initial backoff before retrying transient provider prompt errors |
| `ACP_PROMPT_RETRY_MAX_BACKOFF` | `2m` | Max exponential backoff for transient provider prompt retries |
| `ACTIVITY_REREPORT_INTERVAL` | `60s` | Re-send prompting activity while a prompt is active |
| `ACP_HARNESS_ACTIVITY_REPORT_DEBOUNCE` | `750ms` | Debounce ACP harness/tool-call activity reports before callbacks |
| `ACP_CHECKPOINT_PREEMPT_GRACE` | `30s` | Graceful ACP cancel/close wait before harness force-stop |
| `ACP_CHECKPOINT_PREEMPT_MAX_GRACE` | `2m` | Maximum caller-selected checkpoint rollover grace |
| `ACP_CHECKPOINT_ROLLOVER_TIMEOUT` | `2m` | Full checkpoint restart and strict LoadSession deadline |
Expand Down
1 change: 1 addition & 0 deletions apps/www/src/content/docs/docs/reference/vm-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ Environment variables set by the cloud-init template:
| `ACP_CHECKPOINT_PREEMPT_MAX_GRACE` | `2m` | Maximum `graceMs` accepted by the rollover endpoint |
| `ACP_CHECKPOINT_ROLLOVER_TIMEOUT` | `2m` | Deadline for the complete stop, restart, and strict LoadSession operation |
| `ACP_NOTIF_SERIALIZE_TIMEOUT` | `5s` | Timeout for ACP notification serialization |
| `ACP_HARNESS_ACTIVITY_REPORT_DEBOUNCE` | `750ms` | Debounce window for coalescing ACP harness/tool-call activity reports before POSTing activity callbacks |
| `STANDALONE_CLONE_FILTER` | `blob:none` | Git partial-clone filter for standalone (Cloudflare Container) workspace clones, which run synchronously inside the control plane's create-workspace request (`cloneStandaloneRepository` in `internal/server/standalone_workspace.go`). Set `off` to force full clones. The control plane forwards `CF_CONTAINER_CLONE_FILTER` here. |
| `GRACEFUL_SHUTDOWN_TIMEOUT` | `30s` | Max time to wait for VM-agent HTTP server shutdown after SIGTERM |
| `SYSTEM_PROVISIONING_TIMEOUT` | `15m` | Max time for workspace host provisioning before bootstrap |
Expand Down
3 changes: 3 additions & 0 deletions packages/vm-agent/internal/acp/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,9 @@ type GatewayConfig struct {
// ActivityRereportInterval refreshes prompt activity while a prompt is active.
// Zero disables the periodic re-report loop.
ActivityRereportInterval time.Duration
// HarnessActivityReportDebounce coalesces high-frequency harness lifecycle
// edges before making activity callbacks. Zero uses the package default.
HarnessActivityReportDebounce time.Duration

// Bounds on a single Claude harness lifecycle notification. Zero falls back
// to the package defaults so existing constructions stay safe.
Expand Down
23 changes: 19 additions & 4 deletions packages/vm-agent/internal/acp/gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"time"

"github.com/pelletier/go-toml/v2"
"github.com/workspace/vm-agent/internal/config"
)

// Tests for OAuth support
Expand Down Expand Up @@ -743,19 +744,19 @@ func TestProcessConfig_EnvVarInjection(t *testing.T) {
name: "Mistral Vibe API key uses env var",
agentType: "mistral-vibe",
credential: &agentCredential{
credential: "mistral-api-key-123",
credential: "mistral-placeholder",
credentialKind: "api-key",
},
wantEnvVar: "MISTRAL_API_KEY=mistral-api-key-123",
wantEnvVar: "MISTRAL_API_KEY=mistral-placeholder",
},
{
name: "Amp API key uses env var",
agentType: "amp",
credential: &agentCredential{
credential: "sgamp-api-key-123",
credential: "amp-placeholder",
credentialKind: "api-key",
},
wantEnvVar: "AMP_API_KEY=sgamp-api-key-123",
wantEnvVar: "AMP_API_KEY=amp-placeholder",
},
}

Expand Down Expand Up @@ -1952,6 +1953,20 @@ func TestActivityReportTimeoutPreservesLegacyDefaultAndOverride(t *testing.T) {
}
}

func TestHarnessActivityReportDebouncePreservesDefaultAndOverride(t *testing.T) {
t.Parallel()

h := &SessionHost{config: SessionHostConfig{GatewayConfig: GatewayConfig{}}}
if got := h.harnessActivityReportDebounce(); got != config.DefaultACPHarnessActivityReportDebounce {
t.Fatalf("harnessActivityReportDebounce() = %v, want default %v", got, config.DefaultACPHarnessActivityReportDebounce)
}

h.config.HarnessActivityReportDebounce = 875 * time.Millisecond
if got := h.harnessActivityReportDebounce(); got != 875*time.Millisecond {
t.Fatalf("harnessActivityReportDebounce() = %v, want configured 875ms", got)
}
}

func TestWriteAgentStartupConfigCodexMissingMcpTokenFailsClosed(t *testing.T) {
for _, tc := range []struct {
name string
Expand Down
21 changes: 13 additions & 8 deletions packages/vm-agent/internal/acp/session_host.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,14 +309,19 @@ type SessionHost struct {
harnessWork harnessWorkStatus
harnessTaskIDs map[string]struct{}
harnessActivityCancel context.CancelFunc
// harnessReportPending single-flights the activity report triggered by a
// harness lifecycle notification. The ACP notification goroutine must never
// call reportActivity inline: reportActivity takes mu.RLock for its
// agentType/restartCount/statusErr snapshot, which is exactly the block the
// lock-free mirrors above exist to avoid. Handing the report to a short-lived
// goroutine keeps the notification worker unblocked, and coalescing stops a
// chatty harness from spawning one retried HTTP POST per message.
harnessReportPending atomic.Bool
// harnessReportMu owns the debounced, single-flight activity reporter
// triggered by harness lifecycle notifications. The ACP notification
// goroutine must never call reportActivity inline: reportActivity takes
// mu.RLock for its agentType/restartCount/statusErr snapshot, which is
// exactly the block the lock-free mirrors above exist to avoid.
harnessReportMu sync.Mutex
harnessReportTimer *time.Timer
harnessReportSequence uint64
harnessReportRunning bool
harnessReportPending bool
lastActivityReportMu sync.Mutex
lastActivityReport activityReportSnapshot
lastActivityReportSet bool
// activePromptID identifies the in-flight prompt associated with promptCancel.
// Protected by promptCancelMu.
activePromptID uint64
Expand Down
Loading
Loading