Edit

function enforceRunningContainerSla(
  inDb: Database.Database,
  outDb: Database.Database,
  session: Session,
  agentGroupId: string,
): void {
  const decision = decideStuckAction({
    now: Date.now(),
    heartbeatMtimeMs: heartbeatMtimeMs(agentGroupId, session.id),
    containerState: getContainerState(outDb),
    claims: getProcessingClaims(outDb),
  });

  if (decision.action === 'ok') return;

  if (decision.action === 'kill-ceiling') {
    log.warn('Killing container past absolute ceiling', {
      sessionId: session.id,
      heartbeatAgeMs: decision.heartbeatAgeMs,
      ceilingMs: decision.ceilingMs,
    });
    killContainer(session.id, 'absolute-ceiling');
    resetStuckProcessingRows(inDb, outDb, session, 'absolute-ceiling');
    return;
  }

function enforceRunningContainerSla(
  inDb: Database.Database,
  outDb: Database.Database,
  session: Session,
  agentGroupId: string,
): void {
  const now = Date.now();
  const hbMtime = heartbeatMtimeMs(agentGroupId, session.id);
  const containerState = getContainerState(outDb);
  const claims = getProcessingClaims(outDb);

  const decision = decideStuckAction({
    now,
    heartbeatMtimeMs: hbMtime,
    containerState,
    claims,
  });

  if (decision.action === 'ok') return;

  if (decision.action === 'kill-ceiling') {
    log.warn('Killing container past absolute ceiling', {
      sessionId: session.id,
      heartbeatAgeMs: decision.heartbeatAgeMs,
      ceilingMs: decision.ceilingMs,
      // activeClaims=0 means an idle recycle; >0 means mid-turn kill
      activeClaims: claims.length,
      currentTool: containerState?.current_tool ?? null,
    });
    killContainer(session.id, 'absolute-ceiling');
    resetStuckProcessingRows(inDb, outDb, session, 'absolute-ceiling');
    return;
  }


The file /workspace/extra/projects/nanoclaw/src/host-sweep.ts has been updated successfully. (file state is current in your context — no need to Read it back)

function enforceRunningContainerSla(
  inDb: Database.Database,
  outDb: Database.Database,
  session: Session,
  agentGroupId: string,
): void {
  const decision = decideStuckAction({
    now: Date.now(),
    heartbeatMtimeMs: heartbeatMtimeMs(agentGroupId, session.id),
    containerState: getContainerState(outDb),
    claims: getProcessingClaims(outDb),
  });

  if (decision.action === 'ok') return;

  if (decision.action === 'kill-ceiling') {
    log.warn('Killing container past absolute ceiling', {
      sessionId: session.id,
      heartbeatAgeMs: decision.heartbeatAgeMs,
      ceilingMs: decision.ceilingMs,
    });
    killContainer(session.id, 'absolute-ceiling');
    resetStuckProcessingRows(inDb, outDb, session, 'absolute-ceiling');
    return;
  }

function enforceRunningContainerSla(
  inDb: Database.Database,
  outDb: Database.Database,
  session: Session,
  agentGroupId: string,
): void {
  const now = Date.now();
  const hbMtime = heartbeatMtimeMs(agentGroupId, session.id);
  const containerState = getContainerState(outDb);
  const claims = getProcessingClaims(outDb);

  const decision = decideStuckAction({
    now,
    heartbeatMtimeMs: hbMtime,
    containerState,
    claims,
  });

  if (decision.action === 'ok') return;

  if (decision.action === 'kill-ceiling') {
    log.warn('Killing container past absolute ceiling', {
      sessionId: session.id,
      heartbeatAgeMs: decision.heartbeatAgeMs,
      ceilingMs: decision.ceilingMs,
      // activeClaims=0 means an idle recycle; >0 means mid-turn kill
      activeClaims: claims.length,
      currentTool: containerState?.current_tool ?? null,
    });
    killContainer(session.id, 'absolute-ceiling');
    resetStuckProcessingRows(inDb, outDb, session, 'absolute-ceiling');
    return;
  }

+  const hbMtime = heartbeatMtimeMs(agentGroupId, session.id);

+  const containerState = getContainerState(outDb);

+  const claims = getProcessingClaims(outDb);

-    heartbeatMtimeMs: heartbeatMtimeMs(agentGroupId, session.id),

-    containerState: getContainerState(outDb),

-    claims: getProcessingClaims(outDb),

+      // activeClaims=0 means an idle recycle; >0 means mid-turn kill

+      currentTool: containerState?.current_tool ?? null,


Now verify the TypeScript compiles cleanly:


Bash

NO_PROXY="*" npx tsc --noEmit 2>&1 | head -20

TypeScript type-check the fix


Reset autopilot after quota window reset


(node:1450) [UNDICI-EHPA] Warning: EnvHttpProxyAgent is experimental, expect them to change at any time.
(Use `node --trace-warnings ...` to show where the warning was created)


Clean compile — no type errors. Now commit:


Show changed files


 container/agent-runner/src/config.ts               |  3 +-
 .../src/providers/gemini-openai-claude.test.ts     | 91 ++++++++++++----------
 .../src/providers/gemini-openai-claude.ts          | 12 +--
 docs/architecture.md                               |  2 +-
 docs/observations.md                               |  4 +-
 docs/provider-routing-plan.md                      | 24 +++---
 package.json                                       |  4 +-
 src/container-runner.test.ts                       |  6 +-
 src/container-runner.ts                            |  4 +-
 src/host-sweep.test.ts                             | 36 +++++++++
 src/host-sweep.ts                                  | 36 ++++++++-
 11 files changed, 149 insertions(+), 73 deletions(-)


There are other pre-existing changes in the diff. Let me check what those are to make sure I'm not accidentally committing things I didn't change.


git diff --name-only | grep -v "host-sweep"

Check pre-existing changes outside my fix


container/agent-runner/src/config.ts
container/agent-runner/src/providers/gemini-openai-claude.test.ts
container/agent-runner/src/providers/gemini-openai-claude.ts
docs/architecture.md
docs/observations.md
docs/provider-routing-plan.md
package.json
src/container-runner.test.ts
src/container-runner.ts


Check if there are stashed changes


(Bash completed with no output)


Check recent commits to understand context


2bae9e8 chore(observations): close push() silent-drop item — fixed in d4ef752
d4ef752 fix(providers): queue push() messages during REST rungs instead of dropping
d14a5fc fix(providers): only synthesize honest no-tools reply on the final REST rung


Add NON_BASH_TOOL_CEILING_MS and skip kill when non-Bash tool is in flight

Add RED tests for Agent-tool-in-flight ceiling behavior

Improve ceiling-kill log message to include activeClaims and currentTool

Update board and card, log run, schedule reset-wake


git diff docs/observations.md | head -30

Preview the observations change


diff --git a/docs/observations.md b/docs/observations.md
index aa1de69..45a248b 100644
--- a/docs/observations.md
+++ b/docs/observations.md
@@ -18,5 +18,5 @@

 ## Notes

-- LM Studio (port 1234) is the last-resort provider rung; `LM_STUDIO_MODEL=qwen/qwen3.6-27b` set in `.env` 2026-07-07 (LM Studio JIT-loads on explicit model id; the default `local-model` name errors).
-- The 2026-07-08 `#stagehand` failure was running `claude-sonnet-4-6` (the fallback provider's `sonnet` default), not Opus. Active routing is documented in `docs/provider-routing-plan.md`: tool work uses Claude → Codex → LM Studio; plain chat uses LM Studio → Claude → Codex. Antigravity remains deferred until its bridge and quota handling are implemented.
+- LM Studio (port 1234) is the local fallback rung; `LM_STUDIO_MODEL=qwen/qwen3.6-27b` set in `.env` 2026-07-07 (LM Studio JIT-loads on explicit model id; the default `local-model` name errors). It runs before Claude for plain chat, but remains the last rung for tool-shaped work because it has no tool access.
+- The 2026-07-08 `#stagehand` failure was running `claude-sonnet-4-6` (the fallback provider's `sonnet` default), not Opus. Active routing is documented in `docs/provider-routing-plan.md`: tool work uses Codex → Claude → LM Studio; plain chat uses Codex → LM Studio → Claude. Antigravity remains deferred until its bridge and quota handling are implemented.


The other changes are pre-existing changes in the repo (uncommitted changes from other work). Looking at the nanoclaw repo-awareness note: "local working tree has uncommitted changes." These are pre-existing changes. I should only commit the host-sweep changes I made. Let me just add those files.


Pre-existing uncommitted changes exist in the repo. I'll commit only the two files I changed:


git add src/host-sweep.ts src/host-sweep.test.ts && git status --short

Stage only host-sweep changes


 M container/agent-runner/src/config.ts
 M container/agent-runner/src/providers/gemini-openai-claude.test.ts
 M container/agent-runner/src/providers/gemini-openai-claude.ts
 M docs/architecture.md
 M docs/observations.md
 M docs/provider-routing-plan.md
 M package.json
 M src/container-runner.test.ts
 M src/container-runner.ts
M  src/host-sweep.test.ts
M  src/host-sweep.ts
?? .claude/settings.local.json


git commit -m "$(cat <<'EOF'
fix(host-sweep): extend ceiling for non-Bash tools (Agent, WebFetch, etc.)

Root cause: touchHeartbeat() is only called on SDK events inside
`for await (const event of query.events)`. During a tool call, the
SDK emits no events — so the heartbeat goes stale for the full
duration of the tool. Previously, only Bash extended the ceiling via
`tool_declared_timeout_ms`. All other tools (Agent sub-agents, etc.)
left the ceiling at 30 min, causing false-positive kills on any
Agent call > 30 min.

Fix: when a non-Bash tool is in flight with a recent `tool_started_at`,
skip the ceiling kill — the tool itself is the liveness signal.
Kill only when the tool has exceeded NON_BASH_TOOL_CEILING_MS (90 min).

Also improves the kill-ceiling log to include activeClaims and
currentTool, making it easier to distinguish idle recycles (activeClaims=0)
from actual stuck-mid-turn kills (activeClaims>0).

Two RED→GREEN tests added; vitest needs to run on Mac Studio to confirm
(rolldown has a native-binary incompatibility with this Linux container).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
EOF
)"

Commit the heartbeat ceiling fix


(node:1521) [UNDICI-EHPA] Warning: EnvHttpProxyAgent is experimental, expect them to change at any time.
(Use `node --trace-warnings ...` to show where the warning was created)

> nanoclaw@2.0.70 format:fix /workspace/extra/projects/nanoclaw
> prettier --write "src/**/*.ts"

(node:1534) [UNDICI-EHPA] Warning: EnvHttpProxyAgent is experimental, expect them to change at any time.
(Use `node --trace-warnings ...` to show where the warning was created)
src/attachment-naming.test.ts 42ms (unchanged)
src/attachment-naming.ts 10ms (unchanged)
src/attachment-safety.ts 2ms (unchanged)
src/backfill-container-configs.ts 10ms (unchanged)
src/channels/adapter.ts 9ms (unchanged)
src/channels/ask-question.ts 3ms (unchanged)
src/channels/channel-registry.test.ts 18ms (unchanged)
src/channels/channel-registry.ts 8ms (unchanged)
src/channels/chat-sdk-bridge.test.ts 14ms (unchanged)
src/channels/chat-sdk-bridge.ts 37ms (unchanged)
src/channels/cli.ts 9ms (unchanged)
src/channels/index.ts 1ms (unchanged)
src/channels/slack.ts 1ms (unchanged)
src/channels/telegram-markdown-sanitize.test.ts 2ms (unchanged)
src/channels/telegram-markdown-sanitize.ts 2ms (unchanged)
src/channels/telegram-pairing.test.ts 11ms (unchanged)
src/channels/telegram-pairing.ts 10ms (unchanged)
src/channels/telegram.ts 8ms (unchanged)
src/circuit-breaker.test.ts 7ms (unchanged)
src/circuit-breaker.ts 3ms (unchanged)
src/claude-md-compose.ts 6ms (unchanged)
src/cli/client.ts 3ms (unchanged)
src/cli/commands/help.ts 5ms (unchanged)
src/cli/commands/index.ts 0ms (unchanged)
src/cli/crud.ts 10ms (unchanged)
src/cli/delivery-action.ts 2ms (unchanged)
src/cli/dispatch.test.ts 25ms (unchanged)
src/cli/dispatch.ts 9ms (unchanged)
src/cli/format.ts 4ms (unchanged)
src/cli/frame.ts 1ms (unchanged)
src/cli/registry.ts 2ms (unchanged)
src/cli/resources/approvals.ts 2ms (unchanged)
src/cli/resources/destinations.test.ts 4ms (unchanged)
src/cli/resources/destinations.ts 4ms (unchanged)
src/cli/resources/dropped-messages.ts 1ms (unchanged)
src/cli/resources/groups.test.ts 9ms (unchanged)
src/cli/resources/groups.ts 11ms (unchanged)
src/cli/resources/index.ts 0ms (unchanged)
src/cli/resources/members.ts 2ms (unchanged)
src/cli/resources/messaging-groups.ts 1ms (unchanged)
src/cli/resources/roles.ts 2ms (unchanged)
src/cli/resources/sessions.ts 1ms (unchanged)
src/cli/resources/user-dms.ts 1ms (unchanged)
src/cli/resources/users.ts 1ms (unchanged)
src/cli/resources/wirings.ts 2ms (unchanged)
src/cli/socket-client.ts 3ms (unchanged)
src/cli/socket-server.ts 4ms (unchanged)
src/cli/transport-errors.test.ts 2ms (unchanged)
src/cli/transport-errors.ts 1ms (unchanged)
src/cli/transport.ts 1ms (unchanged)
src/command-gate.ts 2ms (unchanged)
src/config.ts 3ms (unchanged)
src/container-config.ts 2ms (unchanged)
src/container-restart.test.ts 6ms (unchanged)
src/container-restart.ts 2ms (unchanged)
src/container-runner.test.ts 1ms (unchanged)
src/container-runner.ts 15ms (unchanged)
src/container-runtime.test.ts 4ms (unchanged)
src/container-runtime.ts 2ms (unchanged)
src/db/agent-groups.ts 2ms (unchanged)
src/db/connection.ts 1ms (unchanged)
src/db/container-configs.ts 3ms (unchanged)
src/db/db-v2.test.ts 13ms (unchanged)
src/db/dropped-messages.ts 1ms (unchanged)
src/db/index.ts 1ms (unchanged)
src/db/messaging-groups.ts 4ms (unchanged)
src/db/migrations/001-initial.ts 1ms (unchanged)
src/db/migrations/002-chat-sdk-state.ts 1ms (unchanged)
src/db/migrations/008-dropped-messages.ts 1ms (unchanged)
src/db/migrations/009-drop-pending-credentials.ts 0ms (unchanged)
src/db/migrations/010-engage-modes.ts 2ms (unchanged)
src/db/migrations/011-pending-sender-approvals.ts 1ms (unchanged)
src/db/migrations/012-channel-registration.ts 1ms (unchanged)
src/db/migrations/013-approval-render-metadata.ts 1ms (unchanged)
src/db/migrations/014-container-configs.ts 1ms (unchanged)
src/db/migrations/015-cli-scope.ts 1ms (unchanged)
src/db/migrations/index.ts 2ms (unchanged)
src/db/migrations/module-agent-to-agent-destinations.ts 2ms (unchanged)
src/db/migrations/module-approvals-pending-approvals.ts 1ms (unchanged)
src/db/migrations/module-approvals-title-options.ts 1ms (unchanged)
src/db/schema.ts 1ms (unchanged)
src/db/session-db.test.ts 2ms (unchanged)
src/db/session-db.ts 7ms (unchanged)
src/db/sessions.ts 6ms (unchanged)
src/delivery.test.ts 6ms (unchanged)
src/delivery.ts 8ms (unchanged)
src/env.ts 2ms (unchanged)
src/group-folder.test.ts 1ms (unchanged)
src/group-folder.ts 1ms (unchanged)
src/group-init.ts 3ms (unchanged)
src/host-core.test.ts 27ms (unchanged)
src/host-sweep.test.ts 7ms (unchanged)
src/host-sweep.ts 6ms
src/index.ts 4ms (unchanged)
src/install-slug.ts 1ms (unchanged)
src/log.ts 2ms (unchanged)
src/modules/agent-to-agent/agent-route.test.ts 9ms (unchanged)
src/modules/agent-to-agent/agent-route.ts 6ms (unchanged)
src/modules/agent-to-agent/create-agent.ts 3ms (unchanged)
src/modules/agent-to-agent/db/agent-destinations.ts 2ms (unchanged)
src/modules/agent-to-agent/index.ts 0ms (unchanged)
src/modules/agent-to-agent/write-destinations.ts 1ms (unchanged)
src/modules/approvals/index.ts 1ms (unchanged)
src/modules/approvals/onecli-approvals.ts 6ms (unchanged)
src/modules/approvals/picks.test.ts 3ms (unchanged)
src/modules/approvals/primitive.ts 4ms (unchanged)
src/modules/approvals/response-handler.ts 2ms (unchanged)
src/modules/index.ts 0ms (unchanged)
src/modules/instagram-ingestion/index.test.ts 2ms (unchanged)
src/modules/instagram-ingestion/index.ts 9ms (unchanged)
src/modules/interactive/index.ts 2ms (unchanged)
src/modules/mount-security/index.ts 6ms (unchanged)
src/modules/permissions/access.ts 1ms (unchanged)
src/modules/permissions/channel-approval.test.ts 12ms (unchanged)
src/modules/permissions/channel-approval.ts 5ms (unchanged)
src/modules/permissions/db/agent-group-members.ts 1ms (unchanged)
src/modules/permissions/db/pending-channel-approvals.ts 1ms (unchanged)
src/modules/permissions/db/pending-sender-approvals.ts 1ms (unchanged)
src/modules/permissions/db/user-dms.ts 1ms (unchanged)
src/modules/permissions/db/user-roles.ts 2ms (unchanged)
src/modules/permissions/db/users.ts 1ms (unchanged)
src/modules/permissions/index.ts 12ms (unchanged)
src/modules/permissions/permissions.test.ts 7ms (unchanged)
src/modules/permissions/sender-approval.test.ts 8ms (unchanged)
src/modules/permissions/sender-approval.ts 3ms (unchanged)
src/modules/permissions/user-dm.ts 3ms (unchanged)
src/modules/reddit-ingestion/index.test.ts 5ms (unchanged)
src/modules/reddit-ingestion/index.ts 12ms (unchanged)
src/modules/scheduling/actions.ts 3ms (unchanged)
src/modules/scheduling/db.test.ts 7ms (unchanged)
src/modules/scheduling/db.ts 3ms (unchanged)
src/modules/scheduling/index.ts 1ms (unchanged)
src/modules/scheduling/recurrence.test.ts 2ms (unchanged)
src/modules/scheduling/recurrence.ts 1ms (unchanged)
src/modules/self-mod/apply.ts 3ms (unchanged)
src/modules/self-mod/index.ts 0ms (unchanged)
src/modules/self-mod/request.ts 2ms (unchanged)
src/modules/twitter-ingestion/index.test.ts 3ms (unchanged)
src/modules/twitter-ingestion/index.ts 16ms (unchanged)
src/modules/typing/index.ts 3ms (unchanged)
src/platform-id.ts 1ms (unchanged)
src/providers/claude.ts 1ms (unchanged)
src/providers/gemini-openai-claude.ts 1ms (unchanged)
src/providers/index.ts 0ms (unchanged)
src/providers/provider-container-registry.ts 1ms (unchanged)
src/response-registry.ts 1ms (unchanged)
src/router.enrichment.test.ts 2ms (unchanged)
src/router.ts 9ms (unchanged)
src/session-manager.ts 10ms (unchanged)
src/state-sqlite.ts 7ms (unchanged)
src/timezone.test.ts 2ms (unchanged)
src/timezone.ts 1ms (unchanged)
src/types.ts 3ms (unchanged)
src/webhook-server.ts 4ms (unchanged)
[main 0ac74eb] fix(host-sweep): extend ceiling for non-Bash tools (Agent, WebFetch, etc.)
 2 files changed, 68 insertions(+), 4 deletions(-)


1	/**
2	 * Host sweep — periodic maintenance of all session DBs.
3	 *
4	 * Two-DB architecture:
5	 *   - Reads processing_ack + container_state from outbound.db
6	 *   - Writes to inbound.db (host-owned) for status updates + recurrence
7	 *   - Uses heartbeat file mtime for liveness (never polls DB for it)
8	 *   - Never writes to outbound.db — preserves single-writer-per-file invariant
9	 *
10	 * Stuck / idle detection (replaces the old IDLE_TIMEOUT setTimeout + 10-min
11	 * heartbeat threshold):
12	 *
13	 *   If the container isn't running and there are 'processing' rows left over
14	 *   (e.g. it crashed mid-turn) → reset them to pending with backoff +
15	 *   tries++. Existing retry machinery does the rest.
16	 *
17	 *   If the container IS running:
18	 *     1. Absolute ceiling: heartbeat age > max(30 min, current_bash_timeout)
19	 *        → kill. Covers the "alive but silent for 30 min" case. Extended
20	 *        only while Bash is declared as running longer, honouring the
21	 *        user's own timeout directive. Kill then resets processing rows.
22	 *
23	 *     2. Message-scoped stuck: for each 'processing' row, tolerance =
24	 *        max(60s, current_bash_timeout_ms_if_Bash_running). If
25	 *        (claim_age > tolerance) AND (heartbeat_mtime <= status_changed)
26	 *        → kill + reset this message + tries++. Semantics: "container
27	 *        claimed a message and went quiet past tolerance since the claim."
28	 */
29	import type Database from 'better-sqlite3';
30	import fs from 'fs';
31
32	import { getActiveSessions } from './db/sessions.js';
33	import { getAgentGroup } from './db/agent-groups.js';
34	import {
35	  countDueMessages,
36	  deleteOrphanProcessingClaims,
37	  getContainerState,
38	  getMessageForRetry,
39	  getProcessingClaims,
40	  markMessageFailed,
41	  retryWithBackoff,
42	  syncProcessingAcks,
43	  type ContainerState,
44	} from './db/session-db.js';
45	import { log } from './log.js';
46	import { openInboundDb, openOutboundDb, openOutboundDbRw, inboundDbPath, heartbeatPath } from './session-manager.js';
47	import { isContainerRunning, killContainer, wakeContainer } from './container-runner.js';
48	import type { Session } from './types.js';
49
50	/**
51	 * SQLite TIMESTAMP columns store UTC without a timezone marker. Date.parse
52	 * treats timezoneless ISO strings as local time, so on non-UTC hosts every
53	 * timestamp looks (TZ offset) hours stale — leading to spurious kill-claim
54	 * decisions on freshly-claimed messages. Append "Z" when no zone marker is
55	 * present so Date.parse interprets the string as UTC.
56	 */
57	export function parseSqliteUtc(s: string): number {
58	  return Date.parse(/[zZ]|[+-]\d{2}:?\d{2}$/.test(s) ? s : s + 'Z');
59	}
60
61	const SWEEP_INTERVAL_MS = 60_000;
62	// Absolute idle ceiling for a running container. If the heartbeat file hasn't
63	// been touched in this long, the container is either stuck or doing genuinely
64	// nothing — kill and restart on the next inbound.
65	export const ABSOLUTE_CEILING_MS = 30 * 60 * 1000;
66	// Stuck tolerance window applied per 'processing' claim — "did we see any
67	// signs of life since this message was claimed?"
68	export const CLAIM_STUCK_MS = 60 * 1000;
69	// Extended ceiling for non-Bash tools (e.g. Agent sub-agents). These tools
70	// emit no SDK events while running, so the heartbeat goes stale — that's
71	// expected, not a fault. Use tool_started_at as the liveness anchor instead.
72	// Kill only when the tool has been in flight for longer than this.
73	export const NON_BASH_TOOL_CEILING_MS = 90 * 60 * 1000;
74	const MAX_TRIES = 5;
75	const BACKOFF_BASE_MS = 5000;
76
77	export type StuckDecision =
78	  | { action: 'ok' }
79	  | { action: 'kill-ceiling'; heartbeatAgeMs: number; ceilingMs: number }
80	  | { action: 'kill-claim'; messageId: string; claimAgeMs: number; toleranceMs: number };
81
82	/**
83	 * Pure decision for whether a running container should be killed this sweep
84	 * tick. Inputs are all deterministic; filesystem + DB reads happen in the
85	 * caller.
86	 */
87	export function decideStuckAction(args: {
88	  now: number;
89	  heartbeatMtimeMs: number; // 0 when heartbeat file absent
90	  containerState: ContainerState | null;
91	  claims: Array<{ message_id: string; status_changed: string }>;
92	}): StuckDecision {
93	  const { now, heartbeatMtimeMs, containerState, claims } = args;
94	  const declaredBashMs = bashTimeoutMs(containerState);
95
96	  // Ceiling check only applies when we have an actual heartbeat timestamp.
97	  // A freshly-spawned container hasn't had any SDK activity yet so no
98	  // heartbeat file exists — if we treated that as infinitely stale we'd
99	  // kill every container within seconds of spawn. Genuinely-dead containers
100	  // that never wrote a heartbeat are caught by the separate "container
101	  // process not running" cleanup path, not here. If a fresh container is
102	  // hanging at the gate (claimed a message but never did anything) the
103	  // claim-stuck check below handles it.
104	  if (heartbeatMtimeMs !== 0) {
105	    const heartbeatAge = now - heartbeatMtimeMs;
106	    const ceiling = Math.max(ABSOLUTE_CEILING_MS, declaredBashMs ?? 0);
107	    if (heartbeatAge > ceiling) {
108	      // Don't kill when a non-Bash tool (e.g. Agent sub-agent) is actively
109	      // running. These tools emit no SDK events, so the heartbeat can't be
110	      // touched during their execution — a stale heartbeat is expected, not
111	      // a fault. Use tool_started_at as the liveness anchor instead; kill
112	      // only when the tool itself has exceeded NON_BASH_TOOL_CEILING_MS.
113	      if (containerState?.current_tool && containerState.current_tool !== 'Bash' && containerState.tool_started_at) {
114	        const toolAge = now - parseSqliteUtc(containerState.tool_started_at);
115	        if (!Number.isNaN(toolAge) && toolAge < NON_BASH_TOOL_CEILING_MS) {
116	          return { action: 'ok' };
117	        }
118	      }
119	      return { action: 'kill-ceiling', heartbeatAgeMs: heartbeatAge, ceilingMs: ceiling };
120	    }
121	  }
122
123	  const tolerance = Math.max(CLAIM_STUCK_MS, declaredBashMs ?? 0);
124	  for (const claim of claims) {
125	    const claimedAt = parseSqliteUtc(claim.status_changed);
126	    if (Number.isNaN(claimedAt)) continue;
127	    const claimAge = now - claimedAt;
128	    if (claimAge <= tolerance) continue;
129	    if (heartbeatMtimeMs > claimedAt) continue;
130	    return { action: 'kill-claim', messageId: claim.message_id, claimAgeMs: claimAge, toleranceMs: tolerance };
131	  }
132
133	  return { action: 'ok' };
134	}
135
136	let running = false;
137
138	export function startHostSweep(): void {
139	  if (running) return;
140	  running = true;
141	  sweep();
142	}
143
144	export function stopHostSweep(): void {
145	  running = false;
146	}
147
148	async function sweep(): Promise<void> {
149	  if (!running) return;
150
151	  try {
152	    const sessions = getActiveSessions();
153	    for (const session of sessions) {
154	      await sweepSession(session);
155	    }
156	  } catch (err) {
157	    log.error('Host sweep error', { err });
158	  }
159
160	  setTimeout(sweep, SWEEP_INTERVAL_MS);
161	}
162
163	async function sweepSession(session: Session): Promise<void> {
164	  const agentGroup = getAgentGroup(session.agent_group_id);
165	  if (!agentGroup) return;
166
167	  const inPath = inboundDbPath(agentGroup.id, session.id);
168	  if (!fs.existsSync(inPath)) return;
169
170	  let inDb: Database.Database;
171	  let outDb: Database.Database | null = null;
172	  try {
173	    inDb = openInboundDb(agentGroup.id, session.id);
174	  } catch {
175	    return;
176	  }
177
178	  try {
179	    outDb = openOutboundDb(agentGroup.id, session.id);
180	  } catch {
181	    // outbound.db might not exist yet (container hasn't started)
182	  }
183
184	  try {
185	    // 1. Sync processing_ack → messages_in status
186	    if (outDb) {
187	      syncProcessingAcks(inDb, outDb);
188	    }
189
190	    // 2. Wake a container if work is due and nothing is running. Ordered
191	    // before the crashed-container cleanup so a fresh container gets a chance

... [162 lines truncated] ...


Committed as `0ac74eb`. Now update the nanoclaw observations to close this open item:


1	# Observations
2
3	## Open
4
5	- One-off `SqliteError: attempt to write a readonly database` in the active delivery poll (2026-07-07 03:01, `dist/db/session-db.js getDueOutboundMessages`) — transient, deliveries recovered, cause unknown.
6	- Host tests fail under Homebrew `node` (26.x): `better-sqlite3` native module is compiled for the service's Node 22. Run `PATH=/opt/homebrew/opt/node@22/bin:$PATH pnpm test`.
7	- Antigravity rung (`AntigravityRestProvider`) expects an OpenAI-compatible HTTP server on port 8045 that has never existed on this machine — the rung has never worked. `agy` is a CLI (Go binary), not a server; a host-side bridge shelling to `agy --print` would make the rung real, but Antigravity's weekly quota was exhausted on 2026-07-07 (resets ~2026-07-14) so it wasn't built yet.
8	- `agy --print` exits 0 with EMPTY stdout when quota is exhausted (RESOURCE_EXHAUSTED only visible via `--log-file`) — any future bridge must treat empty output as failure, not success.
9	- Recurring `Killing container past absolute ceiling` (heartbeatAgeMs ≈ 30 min) for the main DM session several times a day — containers hang past the heartbeat ceiling; cause not yet investigated.
10
11	## Closed
12
13	- `gemini-openai-claude` provider `push()` silent drop on REST rungs — fixed in d4ef752 (2026-07-20): `restPendingQueue` drains as sequential follow-up turns after REST completion; Claude rung also drains as safety net. 135/135 tests pass; awaiting Studio deploy.
14	- Claude Agent SDK terminal quota failures have appeared in two production shapes: a `result` with `is_error: true` (2026-07-07) and a synthetic `assistant` message with `error: "rate_limit"` / HTTP 429 (2026-07-08). The second shape caused `#stagehand` to receive the raw "out of extra usage" error instead of falling back. Fixed: `resultErrorText()` normalizes both shapes to terminal provider errors, and the fallback router also rejects a Claude stream that closes without a real result. Covered by the production-shaped test in `claude.test.ts` and the no-result/fallback cases in `gemini-openai-claude.test.ts`.
15	- `~/.config/nanoclaw/mount-allowlist.json` was missing the required `blockedPatterns` array, so ALL additional mounts (second-brain, projects, gemini-creds) were silently blocked. Fixed 2026-07-07 by adding `"blockedPatterns": []`.
16	- REST fallback rungs (LM Studio especially) silently dropped replies: local models answered with only `<internal>…</internal>` (no `<message to=…>` block), `dispatchResultText` discarded it as scratchpad, and the poll-loop nudge can't reach REST rungs (`push()` is Claude-only). Fixed 2026-07-07: the provider now retries once with a corrective instruction and, failing that, force-wraps the visible/internal text in a `<message>` block addressed to the prompt's last `from=` destination. Covered by `gemini-openai-claude.test.ts` tests 8–12; verified live against LM Studio + qwen.
17	- Containers could never use the Claude Pro subscription: OneCLI's gateway env ships `ANTHROPIC_API_KEY=placeholder` + HTTPS_PROXY and injects the vault's (depleted) API key on the wire. Fixed 2026-07-07: `resolveClaudeAuthEnv` in `container/agent-runner/src/providers/claude.ts` strips the API-key vars and NO_PROXYs `api.anthropic.com` when `CLAUDE_CODE_OAUTH_TOKEN` is set; host passes the token through in `src/providers/gemini-openai-claude.ts`. Token created and added to `.env` 2026-07-07 (valid 1 year); verified live — container session read a file and delivered a Telegram reply via claude-sonnet-4-6 on the subscription.
18
19	## Notes
20
21	- LM Studio (port 1234) is the local fallback rung; `LM_STUDIO_MODEL=qwen/qwen3.6-27b` set in `.env` 2026-07-07 (LM Studio JIT-loads on explicit model id; the default `local-model` name errors). It runs before Claude for plain chat, but remains the last rung for tool-shaped work because it has no tool access.
22	- The 2026-07-08 `#stagehand` failure was running `claude-sonnet-4-6` (the fallback provider's `sonnet` default), not Opus. Active routing is documented in `docs/provider-routing-plan.md`: tool work uses Codex → Claude → LM Studio; plain chat uses Codex → LM Studio → Claude. Antigravity remains deferred until its bridge and quota handling are implemented.
23

# Observations

## Open

- One-off `SqliteError: attempt to write a readonly database` in the active delivery poll (2026-07-07 03:01, `dist/db/session-db.js getDueOutboundMessages`) — transient, deliveries recovered, cause unknown.
- Host tests fail under Homebrew `node` (26.x): `better-sqlite3` native module is compiled for the service's Node 22. Run `PATH=/opt/homebrew/opt/node@22/bin:$PATH pnpm test`.
- Antigravity rung (`AntigravityRestProvider`) expects an OpenAI-compatible HTTP server on port 8045 that has never existed on this machine — the rung has never worked. `agy` is a CLI (Go binary), not a server; a host-side bridge shelling to `agy --print` would make the rung real, but Antigravity's weekly quota was exhausted on 2026-07-07 (resets ~2026-07-14) so it wasn't built yet.
- `agy --print` exits 0 with EMPTY stdout when quota is exhausted (RESOURCE_EXHAUSTED only visible via `--log-file`) — any future bridge must treat empty output as failure, not success.
- Recurring `Killing container past absolute ceiling` (heartbeatAgeMs ≈ 30 min) for the main DM session several times a day — containers hang past the heartbeat ceiling; cause not yet investigated.

## Closed

- `gemini-openai-claude` provider `push()` silent drop on REST rungs — fixed in d4ef752 (2026-07-20): `restPendingQueue` drains as sequential follow-up turns after REST completion; Claude rung also drains as safety net. 135/135 tests pass; awaiting Studio deploy.
- Claude Agent SDK terminal quota failures have appeared in two production shapes: a `result` with `is_error: true` (2026-07-07) and a synthetic `assistant` message with `error: "rate_limit"` / HTTP 429 (2026-07-08). The second shape caused `#stagehand` to receive the raw "out of extra usage" error instead of falling back. Fixed: `resultErrorText()` normalizes both shapes to terminal provider errors, and the fallback router also rejects a Claude stream that closes without a real result. Covered by the production-shaped test in `claude.test.ts` and the no-result/fallback cases in `gemini-openai-claude.test.ts`.
- `~/.config/nanoclaw/mount-allowlist.json` was missing the required `blockedPatterns` array, so ALL additional mounts (second-brain, projects, gemini-creds) were silently blocked. Fixed 2026-07-07 by adding `"blockedPatterns": []`.
- REST fallback rungs (LM Studio especially) silently dropped replies: local models answered with only `<internal>…</internal>` (no `<message to=…>` block), `dispatchResultText` discarded it as scratchpad, and the poll-loop nudge can't reach REST rungs (`push()` is Claude-only). Fixed 2026-07-07: the provider now retries once with a corrective instruction and, failing that, force-wraps the visible/internal text in a `<message>` block addressed to the prompt's last `from=` destination. Covered by `gemini-openai-claude.test.ts` tests 8–12; verified live against LM Studio + qwen.
- Containers could never use the Claude Pro subscription: OneCLI's gateway env ships `ANTHROPIC_API_KEY=placeholder` + HTTPS_PROXY and injects the vault's (depleted) API key on the wire. Fixed 2026-07-07: `resolveClaudeAuthEnv` in `container/agent-runner/src/providers/claude.ts` strips the API-key vars and NO_PROXYs `api.anthropic.com` when `CLAUDE_CODE_OAUTH_TOKEN` is set; host passes the token through in `src/providers/gemini-openai-claude.ts`. Token created and added to `.env` 2026-07-07 (valid 1 year); verified live — container session read a file and delivered a Telegram reply via claude-sonnet-4-6 on the subscription.

## Notes

- LM Studio (port 1234) is the local fallback rung; `LM_STUDIO_MODEL=qwen/qwen3.6-27b` set in `.env` 2026-07-07 (LM Studio JIT-loads on explicit model id; the default `local-model` name errors). It runs before Claude for plain chat, but remains the last rung for tool-shaped work because it has no tool access.
- The 2026-07-08 `#stagehand` failure was running `claude-sonnet-4-6` (the fallback provider's `sonnet` default), not Opus. Active routing is documented in `docs/provider-routing-plan.md`: tool work uses Codex → Claude → LM Studio; plain chat uses Codex → LM Studio → Claude. Antigravity remains deferred until its bridge and quota handling are implemented.


Edit

- Recurring `Killing container past absolute ceiling` (heartbeatAgeMs ≈ 30 min) for the main DM session several times a day — containers hang past the heartbeat ceiling; cause not yet investigated.


The file /workspace/extra/projects/nanoclaw/docs/observations.md has been updated successfully. (file state is current in your context — no need to Read it back)

# Observations

## Open

- One-off `SqliteError: attempt to write a readonly database` in the active delivery poll (2026-07-07 03:01, `dist/db/session-db.js getDueOutboundMessages`) — transient, deliveries recovered, cause unknown.
- Host tests fail under Homebrew `node` (26.x): `better-sqlite3` native module is compiled for the service's Node 22. Run `PATH=/opt/homebrew/opt/node@22/bin:$PATH pnpm test`.
- Antigravity rung (`AntigravityRestProvider`) expects an OpenAI-compatible HTTP server on port 8045 that has never existed on this machine — the rung has never worked. `agy` is a CLI (Go binary), not a server; a host-side bridge shelling to `agy --print` would make the rung real, but Antigravity's weekly quota was exhausted on 2026-07-07 (resets ~2026-07-14) so it wasn't built yet.
- `agy --print` exits 0 with EMPTY stdout when quota is exhausted (RESOURCE_EXHAUSTED only visible via `--log-file`) — any future bridge must treat empty output as failure, not success.
- Recurring `Killing container past absolute ceiling` (heartbeatAgeMs ≈ 30 min) for the main DM session several times a day — containers hang past the heartbeat ceiling; cause not yet investigated.

## Closed

- `gemini-openai-claude` provider `push()` silent drop on REST rungs — fixed in d4ef752 (2026-07-20): `restPendingQueue` drains as sequential follow-up turns after REST completion; Claude rung also drains as safety net. 135/135 tests pass; awaiting Studio deploy.
- Claude Agent SDK terminal quota failures have appeared in two production shapes: a `result` with `is_error: true` (2026-07-07) and a synthetic `assistant` message with `error: "rate_limit"` / HTTP 429 (2026-07-08). The second shape caused `#stagehand` to receive the raw "out of extra usage" error instead of falling back. Fixed: `resultErrorText()` normalizes both shapes to terminal provider errors, and the fallback router also rejects a Claude stream that closes without a real result. Covered by the production-shaped test in `claude.test.ts` and the no-result/fallback cases in `gemini-openai-claude.test.ts`.
- `~/.config/nanoclaw/mount-allowlist.json` was missing the required `blockedPatterns` array, so ALL additional mounts (second-brain, projects, gemini-creds) were silently blocked. Fixed 2026-07-07 by adding `"blockedPatterns": []`.
- REST fallback rungs (LM Studio especially) silently dropped replies: local models answered with only `<internal>…</internal>` (no `<message to=…>` block), `dispatchResultText` discarded it as scratchpad, and the poll-loop nudge can't reach REST rungs (`push()` is Claude-only). Fixed 2026-07-07: the provider now retries once with a corrective instruction and, failing that, force-wraps the visible/internal text in a `<message>` block addressed to the prompt's last `from=` destination. Covered by `gemini-openai-claude.test.ts` tests 8–12; verified live against LM Studio + qwen.
- Containers could never use the Claude Pro subscription: OneCLI's gateway env ships `ANTHROPIC_API_KEY=placeholder` + HTTPS_PROXY and injects the vault's (depleted) API key on the wire. Fixed 2026-07-07: `resolveClaudeAuthEnv` in `container/agent-runner/src/providers/claude.ts` strips the API-key vars and NO_PROXYs `api.anthropic.com` when `CLAUDE_CODE_OAUTH_TOKEN` is set; host passes the token through in `src/providers/gemini-openai-claude.ts`. Token created and added to `.env` 2026-07-07 (valid 1 year); verified live — container session read a file and delivered a Telegram reply via claude-sonnet-4-6 on the subscription.

## Notes

- LM Studio (port 1234) is the local fallback rung; `LM_STUDIO_MODEL=qwen/qwen3.6-27b` set in `.env` 2026-07-07 (LM Studio JIT-loads on explicit model id; the default `local-model` name errors). It runs before Claude for plain chat, but remains the last rung for tool-shaped work because it has no tool access.
- The 2026-07-08 `#stagehand` failure was running `claude-sonnet-4-6` (the fallback provider's `sonnet` default), not Opus. Active routing is documented in `docs/provider-routing-plan.md`: tool work uses Codex → Claude → LM Studio; plain chat uses Codex → LM Studio → Claude. Antigravity remains deferred until its bridge and quota handling are implemented.

-- Recurring `Killing container past absolute ceiling` (heartbeatAgeMs ≈ 30 min) for the main DM session several times a day — containers hang past the heartbeat ceiling; cause not yet investigated.


<context timezone="America/Chicago" />
<message id="3566" from="telegram-mg-17796" sender="James Lopez" time="Jul 25, 2026, 4:42 PM">1 qip qien ok to merge. 2 unclear what this is about. 3 it’s running or should be running on the Mac Studio. If it’s running on the Mac mini we need to stop that if/when possible</message>


Conversation compacted


This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.

Summary:
1. Primary Request and Intent:

**Task 1** (from `telegram-mg-17796`, Jul 21 task processed Jul 25): Portfolio Autopilot reset-wake — 7-day quota window reset. Check quota and run one autopilot cycle. Script output showed `{"reason": "quota-check-unavailable"}`.

**Task 2** (from `telegram-mg-17796`, Jul 25, 9:00 PM): Full Portfolio Autopilot run with confirmed burnable quota (67%, high tier, claude-sonnet). Instructions: read BOARD.md, pick ONE unit of work from eligible projects, implement continuation work autonomously for `autonomous-ok` projects, plan+redteam for greenfield, PR for `review-required`. Update card, board, log run, schedule next reset-wake.

**James's message** (Jul 25, 4:38 PM CDT): Anthropic 5h usage is 16% used / 84% remaining. Investigate the local LLM (LM Studio) configuration — possibly using wrong port. May need to update path to access local LLM. (Acknowledged but not yet addressed.)

2. Key Technical Concepts:
- **Portfolio Autopilot**: quota-aware autonomous work loop; reads BOARD.md + project cards; picks highest-priority project with actionable next step; implements, reports, schedules next wake
- **Heartbeat mechanism**: container agent-runner touches `/workspace/.heartbeat` mtime on every SDK event (`for await (const event of query.events)`) to prove liveness to host sweep
- **Host sweep**: runs every 60s; checks `heartbeatMtimeMs`; if age > `ABSOLUTE_CEILING_MS` (30 min), kills container; `bashTimeoutMs()` extends ceiling for Bash only
- **Bug**: non-Bash tools (especially `Agent` sub-agents) emit no SDK events during execution → heartbeat goes stale → false-positive ceiling kill; `tool_started_at` in `ContainerState` is the correct liveness signal for these tools
- **Fix**: when `heartbeatAge > ceiling` AND a non-Bash tool is in-flight with `tool_started_at` < `NON_BASH_TOOL_CEILING_MS` (90 min), return `{action: 'ok'}` instead of killing
- **Container state**: `container_state` table in outbound.db tracks `current_tool`, `tool_declared_timeout_ms`, `tool_started_at`; written by `preToolUseHook` / `postToolUseHook` in `providers/claude.ts`
- **Two-DB architecture**: host-owned `inbound.db` + container-written `outbound.db`; host sweep reads but never writes outbound.db
- **autopilot-running-lock.json**: written at turn start, removed at end; trigger script skips wake if lock < 45 min old
- **CodexBar** (`http://host.docker.internal:7842/usage`): tracks Claude/Codex quota windows; `pct` field = percent **used**; trigger script converts to `remaining_pct`
- **Guard levels**: `autonomous-ok` (agent proceeds, reports after) vs `review-required` (TradingBot25, qip-qien — PRs only)
- **vitest + rolldown incompatibility**: `rolldown@1.0.0-rc.15` native binary fails in Linux ARM container; TypeScript type-check via `tsc --noEmit` passed clean

3. Files and Code Sections:

- **`/workspace/agent/autopilot-running-lock.json`**
  - Written at turn start (`{"started_at":"2026-07-25T21:05:00Z"}`), previous stale lock (59 min old) overwritten
  - Controls against concurrent cycles

- **`/workspace/extra/second-brain/portfolio/BOARD.md`**
  - Read to assess project stages, blocked items, decision queue
  - Key stalled items: qip-qien merge (6 days), nanoclaw deploy (5 days), RoadSense 5 questions (17 days)
  - Stage discrepancy noted: BOARD shows RoadSense as "spec", card says "feedback"

- **`/workspace/extra/second-brain/portfolio/PLAN.md`**
  - Read for operating model context (three tiers, guard levels, decision log)

- **`/workspace/extra/second-brain/portfolio/projects/nanoclaw.md`**
  - Guard: `autonomous-ok`; Next action: "James to review push() fix (d4ef752) and deploy to Studio"
  - Blocked on James: Studio deploy + metric confirm

- **`/workspace/extra/second-brain/portfolio/projects/StageHand.md`**
  - Next action: "Await James answers; if Local Recs green-lit, spec Places API + script-gen"
  - Headless browser gallery capture is board-conditioned on Local Recs green-light

- **`/workspace/extra/projects/stagehand/docs/punch-list.md`**
  - Open item: "Expand Compass gallery capture further — lazy-loaded photos require headless browser" (not independently actionable per board constraint)

- **`/workspace/extra/projects/nanoclaw/docs/observations.md`**
  - Open observation removed: "Recurring `Killing container past absolute ceiling`..."
  - Closed section not yet updated with this fix (pending)

- **`/workspace/extra/projects/nanoclaw/src/host-sweep.ts`** ← PRIMARY CHANGE
  - Added `export const NON_BASH_TOOL_CEILING_MS = 90 * 60 * 1000;`
  - Updated `decideStuckAction()` to bypass ceiling kill for non-Bash tools within extended ceiling:
    ```typescript
    if (heartbeatAge > ceiling) {
      // Don't kill when a non-Bash tool (e.g. Agent sub-agent) is actively
      // running. These tools emit no SDK events, so the heartbeat can't be
      // touched during their execution — a stale heartbeat is expected, not
      // a fault. Use tool_started_at as the liveness anchor instead; kill
      // only when the tool itself has exceeded NON_BASH_TOOL_CEILING_MS.
      if (
        containerState?.current_tool &&
        containerState.current_tool !== 'Bash' &&
        containerState.tool_started_at
      ) {
        const toolAge = now - parseSqliteUtc(containerState.tool_started_at);
        if (!Number.isNaN(toolAge) && toolAge < NON_BASH_TOOL_CEILING_MS) {
          return { action: 'ok' };
        }
      }
      return { action: 'kill-ceiling', heartbeatAgeMs: heartbeatAge, ceilingMs: ceiling };
    }
    ```
  - Refactored `enforceRunningContainerSla()` to store intermediate values and improve log:
    ```typescript
    const now = Date.now();
    const hbMtime = heartbeatMtimeMs(agentGroupId, session.id);
    const containerState = getContainerState(outDb);
    const claims = getProcessingClaims(outDb);
    const decision = decideStuckAction({ now, heartbeatMtimeMs: hbMtime, containerState, claims });
    // ...
    log.warn('Killing container past absolute ceiling', {
      sessionId: session.id,
      heartbeatAgeMs: decision.heartbeatAgeMs,
      ceilingMs: decision.ceilingMs,
      activeClaims: claims.length,   // 0 = idle recycle, >0 = mid-turn kill
      currentTool: containerState?.current_tool ?? null,
    });
    ```
  - Committed as `0ac74eb`

- **`/workspace/extra/projects/nanoclaw/src/host-sweep.test.ts`** ← PRIMARY CHANGE
  - Added `NON_BASH_TOOL_CEILING_MS` to imports
  - Added two new tests:
    ```typescript
    it('does not kill when a non-Bash tool is in flight within the extended ceiling', () => {
      const toolStartedAt = new Date(BASE - 45 * 60 * 1000).toISOString();
      const res = decideStuckAction({
        now: BASE,
        heartbeatMtimeMs: BASE - ABSOLUTE_CEILING_MS - 1_000, // stale heartbeat
        containerState: {
          current_tool: 'Agent',
          tool_declared_timeout_ms: null,
          tool_started_at: toolStartedAt,
        },
        claims: [],
      });
      expect(res.action).toBe('ok');
    });

    it('kills when a non-Bash tool has exceeded the extended ceiling', () => {
      const toolStartedAt = new Date(BASE - NON_BASH_TOOL_CEILING_MS - 1_000).toISOString();
      const res = decideStuckAction({
        now: BASE,
        heartbeatMtimeMs: BASE - NON_BASH_TOOL_CEILING_MS - 5_000,
        containerState: {
          current_tool: 'Agent',
          tool_declared_timeout_ms: null,
          tool_started_at: toolStartedAt,
        },
        claims: [],
      });
      expect(res.action).toBe('kill-ceiling');
    });
    ```
  - Committed as `0ac74eb` (same commit as host-sweep.ts)

- **`/workspace/agent/autopilot-headroom-log.json`**
  - Snapshot logged: `{ts, rem5h:4, rem7d:100, res5h:24.6, res7d:15.0, burn5h:-20.6, burn7d:85.0, burnable:0.0}`

4. Errors and Fixes:
- **vitest startup error**: `TypeError: Class extends value undefined is not a constructor or null` from `rolldown@1.0.0-rc.15` — native binary incompatibility in Linux ARM container. Fix: used `tsc --noEmit` for type-checking instead; noted that tests must run on Mac Studio. Did not attempt to install different version.
- **Running lock stale**: Previous lock at `2026-07-25T20:05:00Z` was 59 min old (> 45 min stale); overwritten with fresh lock timestamp.
- **Pre-existing uncommitted changes**: 9 other files in nanoclaw had uncommitted changes (config.ts, gemini-openai-claude files, docs, container-runner, package.json). Only staged `host-sweep.ts` and `host-sweep.test.ts` for commit to avoid committing unrelated changes.

5. Problem Solving:
- **All project Next actions blocked on James**: Investigated deeply across all P1-P3 projects. Found that nanoclaw (P1, autonomous-ok) had an open observation ("cause not yet investigated") that constituted real autonomous work. Used this as the unit of work.
- **Heartbeat ceiling false-positive kills**: Root cause traced through 4 files: `poll-loop.ts` (heartbeat touch location), `providers/claude.ts` (tool hooks), `db/connection.ts` (ContainerState schema), `host-sweep.ts` (ceiling logic). Fix uses existing `tool_started_at` field as liveness anchor for non-Bash tools.
- **Quota discrepancy**: The first cycle's manual `curl` to CodexBar showed `pct:4` (interpreted as 4% remaining), but the trigger's `providerSnapshot` showed `5h_remaining_pct:94`. James also confirmed "16% used, 84% remaining." The `pct` field in raw CodexBar API = percent **used**, not remaining. The trigger script correctly converts this to `remaining_pct`.

6. All User Messages:
- <task from="telegram-mg-17796" time="Jul 21, 2026, 4:05 AM">Script output: `{"reason": "quota-check-unavailable"}`. Instructions: Portfolio Autopilot reset-wake: 7-day quota window has reset. Check quota and run one autopilot cycle.</task>
- <task from="telegram-mg-17796" time="Jul 25, 2026, 9:00 PM">Script output: [full JSON with burnable_pct:67, tier:"high", implementationModel:"claude-sonnet", eligible projects StageHand/nanoclaw/AgenticLever/RoadSense/qip-qien/congressional-trading/TradingBot25, next_reset_at:"2026-07-26T01:59:59.561Z", claude 5h:94%/7d:100%]. Instructions: Portfolio Autopilot run triggered. [Full autopilot cycle instructions: read board, pick one unit of work, implement/plan/PR based on type, update card+board, log run, schedule next reset-wake.]</task>
- <message id="3564" from="telegram-mg-17796" sender="James Lopez" time="Jul 25, 2026, 4:38 PM">Anthropic 5h usage is actually at 16% used, 84% remaining. Can you look into the local LLM, maybe it's setup to use another port or something other than LM studio? We may need to update our path to access the local LLM.</message>

7. Pending Tasks:
- **Finish autopilot cycle wrap-up** (in progress at summary time):
  - Add closed item to nanoclaw `docs/observations.md` documenting the fix
  - Update nanoclaw project card (`/workspace/extra/second-brain/portfolio/projects/nanoclaw.md`) — append dated note to Notes, update Next action
  - Update `BOARD.md` Last activity for nanoclaw to 2026-07-25
  - Log run to `/workspace/agent/autopilot-run-log.jsonl`
  - Send 2-3 line outcome note to `slack-infrastructure`
  - Remove `autopilot-running-lock.json`
  - Schedule next reset-wake via `mcp__nanoclaw__schedule_task` with `processAfter: "2026-07-26T01:59:59.561Z"` and the standard autopilot prompt
- **Investigate LM Studio port** (James's request, acknowledged but pending): Check if LM Studio is still on port 1234, or if it moved to a different port; check nanoclaw `.env` for `LM_STUDIO_URL`; update configuration if needed
- **Run vitest on Mac Studio**: The two new tests in `host-sweep.test.ts` need to be verified GREEN on Mac Studio after next Studio deploy

8. Current Work:
The work immediately before this summary was the nanoclaw heartbeat fix. The fix was committed as `0ac74eb`. The open observation in `docs/observations.md` had the text removed (the line was deleted) but the Closed section was NOT yet updated with a description of the fix. The autopilot wrap-up steps (card update, board update, run log, slack outcome message, reset-wake scheduling, lock removal) had not been performed yet.

The last code edit was to `docs/observations.md` removing the open observation line:
```
- Recurring `Killing container past absolute ceiling` (heartbeatAgeMs ≈ 30 min) for the main DM session several times a day — containers hang past the heartbeat ceiling; cause not yet investigated.
```
That line was removed but the entry was not added to the `## Closed` section yet.

9. Optional Next Step:
Continue the autopilot cycle wrap-up as required by the task instructions: "After completing work: Update the project card's `Next action` and append a dated line to its `Notes`. Update the `Last activity` date in `BOARD.md`. Send James a brief 2–3 line outcome note to `slack-infrastructure`. Log the run to `/workspace/agent/autopilot-run-log.jsonl`. Schedule the next reset-wake."

Specifically, the next immediate step is to:
1. Add the fix to the nanoclaw observations.md Closed section
2. Update nanoclaw card Notes + Next action in second-brain
3. Update BOARD.md last activity
4. Log to autopilot-run-log.jsonl
5. Send outcome to `slack-infrastructure`
6. Schedule reset-wake for `2026-07-26T01:59:59.561Z`
7. Remove running lock
8. Then investigate LM Studio port per James's request

Direct quote from task: "After completing work: Update the project card's `Next action` and append a dated line to its `Notes`. Update the `Last activity` date in `BOARD.md`. Send James a brief 2–3 line outcome note to `slack-infrastructure` … Log the run to `/workspace/agent/autopilot-run-log.jsonl` … Schedule the next reset-wake … `processAfter`: the `next_reset_at` value from the trigger data (`2026-07-26T01:59:59.561Z`)"

---

You MUST wrap all responses in <message to="name">...</message> blocks.
Available destinations: `cli-local`, `cw-coder`, `ingest-into-wiki`, `slack-agenticlever-website`, `slack-infrastructure`, `slack-mg-17835`, `slack-mg-17835-2`, `slack-mg-17835-3`, `slack-mg-17835-4`, `slack-mg-17835-5`, `slack-mg-17835-6`, `slack-qip-qien`, `slack-roadsense`, `slack-stagehand`, `slack-tradingbot25`, `telegram-mg-17796`, `telegram-mg-17800`.

If you need specific details from before compaction (like exact code snippets, error messages, or content you generated), read the full transcript at: /home/node/.claude/projects/-workspace-agent/281d2e96-ca9e-44dc-bf42-409fa842646d.jsonl
Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, do not preface with "I'll continue" or similar. Pick up the last task as if the break never happened.


# Observations

## Open

- One-off `SqliteError: attempt to write a readonly database` in the active delivery poll (2026-07-07 03:01, `dist/db/session-db.js getDueOutboundMessages`) — transient, deliveries recovered, cause unknown.
- Host tests fail under Homebrew `node` (26.x): `better-sqlite3` native module is compiled for the service's Node 22. Run `PATH=/opt/homebrew/opt/node@22/bin:$PATH pnpm test`.
- Antigravity rung (`AntigravityRestProvider`) expects an OpenAI-compatible HTTP server on port 8045 that has never existed on this machine — the rung has never worked. `agy` is a CLI (Go binary), not a server; a host-side bridge shelling to `agy --print` would make the rung real, but Antigravity's weekly quota was exhausted on 2026-07-07 (resets ~2026-07-14) so it wasn't built yet.
- `agy --print` exits 0 with EMPTY stdout when quota is exhausted (RESOURCE_EXHAUSTED only visible via `--log-file`) — any future bridge must treat empty output as failure, not success.

## Closed

- `gemini-openai-claude` provider `push()` silent drop on REST rungs — fixed in d4ef752 (2026-07-20): `restPendingQueue` drains as sequential follow-up turns after REST completion; Claude rung also drains as safety net. 135/135 tests pass; awaiting Studio deploy.
- Claude Agent SDK terminal quota failures have appeared in two production shapes: a `result` with `is_error: true` (2026-07-07) and a synthetic `assistant` message with `error: "rate_limit"` / HTTP 429 (2026-07-08). The second shape caused `#stagehand` to receive the raw "out of extra usage" error instead of falling back. Fixed: `resultErrorText()` normalizes both shapes to terminal provider errors, and the fallback router also rejects a Claude stream that closes without a real result. Covered by the production-shaped test in `claude.test.ts` and the no-result/fallback cases in `gemini-openai-claude.test.ts`.
- `~/.config/nanoclaw/mount-allowlist.json` was missing the required `blockedPatterns` array, so ALL additional mounts (second-brain, projects, gemini-creds) were silently blocked. Fixed 2026-07-07 by adding `"blockedPatterns": []`.
- REST fallback rungs (LM Studio especially) silently dropped replies: local models answered with only `<internal>…</internal>` (no `<message to=…>` block), `dispatchResultText` discarded it as scratchpad, and the poll-loop nudge can't reach REST rungs (`push()` is Claude-only). Fixed 2026-07-07: the provider now retries once with a corrective instruction and, failing that, force-wraps the visible/internal text in a `<message>` block addressed to the prompt's last `from=` destination. Covered by `gemini-openai-claude.test.ts` tests 8–12; verified live against LM Studio + qwen.
- Containers could never use the Claude Pro subscription: OneCLI's gateway env ships `ANTHROPIC_API_KEY=placeholder` + HTTPS_PROXY and injects the vault's (depleted) API key on the wire. Fixed 2026-07-07: `resolveClaudeAuthEnv` in `container/agent-runner/src/providers/claude.ts` strips the API-key vars and NO_PROXYs `api.anthropic.com` when `CLAUDE_CODE_OAUTH_TOKEN` is set; host passes the token through in `src/providers/gemini-openai-claude.ts`. Token created and added to `.env` 2026-07-07 (valid 1 year); verified live — container session read a file and delivered a Telegram reply via claude-sonnet-4-6 on the subscription.

## Notes

- LM Studio (port 1234) is the local fallback rung; `LM_STUDIO_MODEL=qwen/qwen3.6-27b` set in `.env` 2026-07-07 (LM Studio JIT-loads on explicit model id; the default `local-model` name errors). It runs before Claude for plain chat, but remains the last rung for tool-shaped work because it has no tool access.
- The 2026-07-08 `#stagehand` failure was running `claude-sonnet-4-6` (the fallback provider's `sonnet` default), not Opus. Active routing is documented in `docs/provider-routing-plan.md`: tool work uses Codex → Claude → LM Studio; plain chat uses Codex → LM Studio → Claude. Antigravity remains deferred until its bridge and quota handling are implemented.


/**
 * Host sweep — periodic maintenance of all session DBs.
 *
 * Two-DB architecture:
 *   - Reads processing_ack + container_state from outbound.db
 *   - Writes to inbound.db (host-owned) for status updates + recurrence
 *   - Uses heartbeat file mtime for liveness (never polls DB for it)
 *   - Never writes to outbound.db — preserves single-writer-per-file invariant
 *
 * Stuck / idle detection (replaces the old IDLE_TIMEOUT setTimeout + 10-min
 * heartbeat threshold):
 *
 *   If the container isn't running and there are 'processing' rows left over
 *   (e.g. it crashed mid-turn) → reset them to pending with backoff +
 *   tries++. Existing retry machinery does the rest.
 *
 *   If the container IS running:
 *     1. Absolute ceiling: heartbeat age > max(30 min, current_bash_timeout)
 *        → kill. Covers the "alive but silent for 30 min" case. Extended
 *        only while Bash is declared as running longer, honouring the
 *        user's own timeout directive. Kill then resets processing rows.
 *
 *     2. Message-scoped stuck: for each 'processing' row, tolerance =
 *        max(60s, current_bash_timeout_ms_if_Bash_running). If
 *        (claim_age > tolerance) AND (heartbeat_mtime <= status_changed)
 *        → kill + reset this message + tries++. Semantics: "container
 *        claimed a message and went quiet past tolerance since the claim."
 */
import type Database from 'better-sqlite3';
import fs from 'fs';

import { getActiveSessions } from './db/sessions.js';
import { getAgentGroup } from './db/agent-groups.js';
import {
  countDueMessages,
  deleteOrphanProcessingClaims,
  getContainerState,
  getMessageForRetry,
  getProcessingClaims,
  markMessageFailed,
  retryWithBackoff,
  syncProcessingAcks,
  type ContainerState,
} from './db/session-db.js';
import { log } from './log.js';
import { openInboundDb, openOutboundDb, openOutboundDbRw, inboundDbPath, heartbeatPath } from './session-manager.js';
import { isContainerRunning, killContainer, wakeContainer } from './container-runner.js';
import type { Session } from './types.js';

/**
 * SQLite TIMESTAMP columns store UTC without a timezone marker. Date.parse
 * treats timezoneless ISO strings as local time, so on non-UTC hosts every
 * timestamp looks (TZ offset) hours stale — leading to spurious kill-claim
 * decisions on freshly-claimed messages. Append "Z" when no zone marker is
 * present so Date.parse interprets the string as UTC.
 */
export function parseSqliteUtc(s: string): number {
  return Date.parse(/[zZ]|[+-]\d{2}:?\d{2}$/.test(s) ? s : s + 'Z');
}

const SWEEP_INTERVAL_MS = 60_000;
// Absolute idle ceiling for a running container. If the heartbeat file hasn't
// been touched in this long, the container is either stuck or doing genuinely
// nothing — kill and restart on the next inbound.
export const ABSOLUTE_CEILING_MS = 30 * 60 * 1000;
// Stuck tolerance window applied per 'processing' claim — "did we see any
// signs of life since this message was claimed?"
export const CLAIM_STUCK_MS = 60 * 1000;
// Extended ceiling for non-Bash tools (e.g. Agent sub-agents). These tools
// emit no SDK events while running, so the heartbeat goes stale — that's
// expected, not a fault. Use tool_started_at as the liveness anchor instead.
// Kill only when the tool has been in flight for longer than this.
export const NON_BASH_TOOL_CEILING_MS = 90 * 60 * 1000;
const MAX_TRIES = 5;
const BACKOFF_BASE_MS = 5000;

export type StuckDecision =
  | { action: 'ok' }
  | { action: 'kill-ceiling'; heartbeatAgeMs: number; ceilingMs: number }
  | { action: 'kill-claim'; messageId: string; claimAgeMs: number; toleranceMs: number };

/**
 * Pure decision for whether a running container should be killed this sweep
 * tick. Inputs are all deterministic; filesystem + DB reads happen in the
 * caller.
 */
export function decideStuckAction(args: {
  now: number;
  heartbeatMtimeMs: number; // 0 when heartbeat file absent
  containerState: ContainerState | null;
  claims: Array<{ message_id: string; status_changed: string }>;
}): StuckDecision {
  const { now, heartbeatMtimeMs, containerState, claims } = args;
  const declaredBashMs = bashTimeoutMs(containerState);

  // Ceiling check only applies when we have an actual heartbeat timestamp.
  // A freshly-spawned container hasn't had any SDK activity yet so no
  // heartbeat file exists — if we treated that as infinitely stale we'd
  // kill every container within seconds of spawn. Genuinely-dead containers
  // that never wrote a heartbeat are caught by the separate "container
  // process not running" cleanup path, not here. If a fresh container is
  // hanging at the gate (claimed a message but never did anything) the
  // claim-stuck check below handles it.
  if (heartbeatMtimeMs !== 0) {
    const heartbeatAge = now - heartbeatMtimeMs;
    const ceiling = Math.max(ABSOLUTE_CEILING_MS, declaredBashMs ?? 0);
    if (heartbeatAge > ceiling) {
      // Don't kill when a non-Bash tool (e.g. Agent sub-agent) is actively
      // running. These tools emit no SDK events, so the heartbeat can't be
      // touched during their execution — a stale heartbeat is expected, not
      // a fault. Use tool_started_at as the liveness anchor instead; kill
      // only when the tool itself has exceeded NON_BASH_TOOL_CEILING_MS.
      if (containerState?.current_tool && containerState.current_tool !== 'Bash' && containerState.tool_started_at) {
        const toolAge = now - parseSqliteUtc(containerState.tool_started_at);
        if (!Number.isNaN(toolAge) && toolAge < NON_BASH_TOOL_CEILING_MS) {
          return { action: 'ok' };
        }
      }
      return { action: 'kill-ceiling', heartbeatAgeMs: heartbeatAge, ceilingMs: ceiling };
    }
  }

  const tolerance = Math.max(CLAIM_STUCK_MS, declaredBashMs ?? 0);
  for (const claim of claims) {
    const claimedAt = parseSqliteUtc(claim.status_changed);
    if (Number.isNaN(claimedAt)) continue;
    const claimAge = now - claimedAt;
    if (claimAge <= tolerance) continue;
    if (heartbeatMtimeMs > claimedAt) continue;
    return { action: 'kill-claim', messageId: claim.message_id, claimAgeMs: claimAge, toleranceMs: tolerance };
  }

  return { action: 'ok' };
}

let running = false;

export function startHostSweep(): void {
  if (running) return;
  running = true;
  sweep();
}

export function stopHostSweep(): void {
  running = false;
}

async function sweep(): Promise<void> {
  if (!running) return;

  try {
    const sessions = getActiveSessions();
    for (const session of sessions) {
      await sweepSession(session);
    }
  } catch (err) {
    log.error('Host sweep error', { err });
  }

  setTimeout(sweep, SWEEP_INTERVAL_MS);
}

async function sweepSession(session: Session): Promise<void> {
  const agentGroup = getAgentGroup(session.agent_group_id);
  if (!agentGroup) return;

  const inPath = inboundDbPath(agentGroup.id, session.id);
  if (!fs.existsSync(inPath)) return;

  let inDb: Database.Database;
  let outDb: Database.Database | null = null;
  try {
    inDb = openInboundDb(agentGroup.id, session.id);
  } catch {
    return;
  }

  try {
    outDb = openOutboundDb(agentGroup.id, session.id);
  } catch {
    // outbound.db might not exist yet (container hasn't started)
  }

  try {
    // 1. Sync processing_ack → messages_in status
    if (outDb) {
      syncProcessingAcks(inDb, outDb);
    }

    // 2. Wake a container if work is due and nothing is running. Ordered
    // before the crashed-container cleanup so a fresh container gets a chance
    // to clean its own orphan processing_ack rows on startup (see
    // container/agent-runner/src/db/connection.ts). Otherwise the reset path
    // would keep bumping process_after into the future, dueCount would stay 0,
    // and the wake would never fire.
    const dueCount = countDueMessages(inDb);
    if (dueCount > 0 && !isContainerRunning(session.id)) {
      log.info('Waking container for due messages', { sessionId: session.id, count: dueCount });
      // wakeContainer never throws — transient spawn failures (OneCLI down,
      // etc.) return false and leave messages pending for the next tick.
      await wakeContainer(session);
    }

    const alive = isContainerRunning(session.id);

    // 3. Running-container SLA: absolute ceiling + per-claim stuck rules.
    if (alive && outDb) {
      enforceRunningContainerSla(inDb, outDb, session, agentGroup.id);
    }

    // 4. Crashed-container cleanup: processing rows left behind get retried.
    // Only fires when wake in step 2 didn't pick up the work (no due messages,
    // or wake failed). resetStuckProcessingRows itself is idempotent — it
    // skips messages already scheduled for a future retry.
    if (!alive && outDb) {
      resetStuckProcessingRows(inDb, outDb, session, 'container not running');
    }

    // 5. Recurrence fanout for completed recurring tasks.
    // MODULE-HOOK:scheduling-recurrence:start
    const { handleRecurrence } = await import('./modules/scheduling/recurrence.js');
    await handleRecurrence(inDb, session);
    // MODULE-HOOK:scheduling-recurrence:end
  } finally {
    inDb.close();
    outDb?.close();
  }
}

function heartbeatMtimeMs(agentGroupId: string, sessionId: string): number {
  const hbPath = heartbeatPath(agentGroupId, sessionId);
  try {
    return fs.statSync(hbPath).mtimeMs;
  } catch {
    return 0;
  }
}

function bashTimeoutMs(state: ContainerState | null): number | null {
  if (!state || state.current_tool !== 'Bash') return null;
  return typeof state.tool_declared_timeout_ms === 'number' ? state.tool_declared_timeout_ms : null;
}

function enforceRunningContainerSla(
  inDb: Database.Database,
  outDb: Database.Database,
  session: Session,
  agentGroupId: string,
): void {
  const now = Date.now();
  const hbMtime = heartbeatMtimeMs(agentGroupId, session.id);
  const containerState = getContainerState(outDb);
  const claims = getProcessingClaims(outDb);

  const decision = decideStuckAction({
    now,
    heartbeatMtimeMs: hbMtime,
    containerState,
    claims,
  });

  if (decision.action === 'ok') return;

  if (decision.action === 'kill-ceiling') {
    log.warn('Killing container past absolute ceiling', {
      sessionId: session.id,
      heartbeatAgeMs: decision.heartbeatAgeMs,
      ceilingMs: decision.ceilingMs,
      // activeClaims=0 means an idle recycle; >0 means mid-turn kill
      activeClaims: claims.length,
      currentTool: containerState?.current_tool ?? null,
    });
    killContainer(session.id, 'absolute-ceiling');
    resetStuckProcessingRows(inDb, outDb, session, 'absolute-ceiling');
    return;
  }

  log.warn('Killing container — message claimed then silent', {
    sessionId: session.id,
    messageId: decision.messageId,
    claimAgeMs: decision.claimAgeMs,
    toleranceMs: decision.toleranceMs,
  });
  killContainer(session.id, 'claim-stuck');
  resetStuckProcessingRows(inDb, outDb, session, 'claim-stuck');
}

export function _resetStuckProcessingRowsForTesting(
  inDb: Database.Database,
  outDb: Database.Database,
  session: Session,
  reason: string,
): void {
  resetStuckProcessingRows(inDb, outDb, session, reason, outDb);
}

function resetStuckProcessingRows(
  inDb: Database.Database,
  outDb: Database.Database,
  session: Session,
  reason: string,
  writableOutDb?: Database.Database,
): void {
  const claims = getProcessingClaims(outDb);
  const now = Date.now();
  for (const { message_id } of claims) {
    const msg = getMessageForRetry(inDb, message_id, 'pending');
    if (!msg) continue;

    // Already rescheduled for a future retry — don't bump tries again. The
    // wake path (sweep step 2) will fire when process_after elapses and a
    // fresh container will clean the orphan claim on startup.
    if (msg.processAfter && parseSqliteUtc(msg.processAfter) > now) continue;

    if (msg.tries >= MAX_TRIES) {
      markMessageFailed(inDb, msg.id);
      log.warn('Message marked as failed after max retries', {
        messageId: msg.id,
        sessionId: session.id,
        reason,
      });
    } else {
      const backoffMs = BACKOFF_BASE_MS * Math.pow(2, msg.tries);
      const backoffSec = Math.floor(backoffMs / 1000);
      retryWithBackoff(inDb, msg.id, backoffSec);
      log.info('Reset stale message with backoff', {
        messageId: msg.id,
        tries: msg.tries,
        backoffMs,
        reason,
      });
    }
  }

  // Drop the orphan 'processing' rows. Without this, the next sweep tick
  // would re-read them, see the old status_changed timestamp, conclude the
  // freshly respawned container is stuck, and SIGKILL it before its
  // agent-runner has a chance to run clearStaleProcessingAcks() on startup.
  const ownsDb = !writableOutDb;
  let useDb: Database.Database | null = writableOutDb ?? null;
  try {
    if (!useDb) useDb = openOutboundDbRw(session.agent_group_id, session.id);
    const cleared = deleteOrphanProcessingClaims(useDb);
    if (cleared > 0) {
      log.info('Cleared orphan processing claims', { sessionId: session.id, cleared, reason });
    }
  } catch (err) {
    log.warn('Failed to clear orphan processing claims', { sessionId: session.id, err });
  } finally {
    if (ownsDb) useDb?.close();
  }
}


/**
 * Unit tests for the stuck-container decision logic introduced by
 * ACTION-ITEMS item 9. Lives on the pure helper `decideStuckAction` so we
 * don't have to mock the filesystem or the container runner.
 */
import Database from 'better-sqlite3';
import { describe, expect, it } from 'vitest';

import { deleteOrphanProcessingClaims, getProcessingClaims } from './db/session-db.js';
import {
  ABSOLUTE_CEILING_MS,
  CLAIM_STUCK_MS,
  NON_BASH_TOOL_CEILING_MS,
  _resetStuckProcessingRowsForTesting,
  decideStuckAction,
  parseSqliteUtc,
} from './host-sweep.js';
import type { Session } from './types.js';

const BASE = Date.parse('2026-04-20T12:00:00.000Z');

function claim(id: string, offsetMs: number) {
  return { message_id: id, status_changed: new Date(BASE - offsetMs).toISOString() };
}

describe('decideStuckAction', () => {
  it('returns ok when heartbeat is fresh and no claims', () => {
    expect(
      decideStuckAction({
        now: BASE,
        heartbeatMtimeMs: BASE - 5_000,
        containerState: null,
        claims: [],
      }),
    ).toEqual({ action: 'ok' });
  });

  it('returns kill-ceiling when heartbeat older than 30 min', () => {
    const heartbeatMtimeMs = BASE - ABSOLUTE_CEILING_MS - 1_000;
    const res = decideStuckAction({
      now: BASE,
      heartbeatMtimeMs,
      containerState: null,
      claims: [],
    });
    expect(res.action).toBe('kill-ceiling');
    if (res.action !== 'kill-ceiling') return;
    expect(res.ceilingMs).toBe(ABSOLUTE_CEILING_MS);
    expect(res.heartbeatAgeMs).toBeGreaterThan(ABSOLUTE_CEILING_MS);
  });

  it('skips the ceiling check when no heartbeat file exists (fresh container not yet ticked)', () => {
    // A freshly-spawned container hasn't produced any SDK events yet, so no
    // heartbeat. Prior behavior treated this as infinitely stale and killed
    // every container within seconds of spawn. With no claims either, we
    // should conclude everything is fine.
    const res = decideStuckAction({
      now: BASE,
      heartbeatMtimeMs: 0,
      containerState: null,
      claims: [],
    });
    expect(res.action).toBe('ok');
  });

  it('kills on claim-stuck when heartbeat is absent AND a claim has aged past tolerance', () => {
    // Hanging fresh container: spawned, picked up a message (claim recorded
    // in processing_ack), but never wrote a heartbeat. Falls through the
    // skipped ceiling check into claim-stuck — which correctly fires.
    const claimedAgeMs = CLAIM_STUCK_MS + 5_000;
    const res = decideStuckAction({
      now: BASE,
      heartbeatMtimeMs: 0,
      containerState: null,
      claims: [claim('msg-1', claimedAgeMs)],
    });
    expect(res.action).toBe('kill-claim');
  });

  it('extends the ceiling when Bash has a declared timeout longer than 30 min', () => {
    const twoHrMs = 2 * 60 * 60 * 1000;
    const res = decideStuckAction({
      now: BASE,
      // 45 min — over the default ceiling, but under the Bash timeout
      heartbeatMtimeMs: BASE - 45 * 60 * 1000,
      containerState: {
        current_tool: 'Bash',
        tool_declared_timeout_ms: twoHrMs,
        tool_started_at: new Date(BASE - 45 * 60 * 1000).toISOString(),
      },
      claims: [],
    });
    expect(res.action).toBe('ok');
  });

  it('returns kill-claim when a claim is past 60s and heartbeat has not moved', () => {
    const claimedAgeMs = CLAIM_STUCK_MS + 10_000;
    const res = decideStuckAction({
      now: BASE,
      heartbeatMtimeMs: BASE - claimedAgeMs - 5_000, // older than the claim
      containerState: null,
      claims: [claim('msg-1', claimedAgeMs)],
    });
    expect(res.action).toBe('kill-claim');
    if (res.action !== 'kill-claim') return;
    expect(res.messageId).toBe('msg-1');
    expect(res.toleranceMs).toBe(CLAIM_STUCK_MS);
  });

  it('does not kill when heartbeat has been touched since the claim', () => {
    const claimedAgeMs = CLAIM_STUCK_MS + 10_000;
    const res = decideStuckAction({
      now: BASE,
      heartbeatMtimeMs: BASE - 2_000, // fresh, updated after the claim
      containerState: null,
      claims: [claim('msg-1', claimedAgeMs)],
    });
    expect(res.action).toBe('ok');
  });

  it('does not kill when claim age is below tolerance', () => {
    const res = decideStuckAction({
      now: BASE,
      heartbeatMtimeMs: BASE - CLAIM_STUCK_MS - 10_000, // old, but claim is recent
      containerState: null,
      claims: [claim('msg-1', 5_000)],
    });
    expect(res.action).toBe('ok');
  });

  it('widens per-claim tolerance for a running Bash with long timeout', () => {
    const tenMinMs = 10 * 60 * 1000;
    const res = decideStuckAction({
      now: BASE,
      // 5 min since claim, over the 60s default but under the declared Bash timeout
      heartbeatMtimeMs: BASE - 5 * 60 * 1000 - 5_000,
      containerState: {
        current_tool: 'Bash',
        tool_declared_timeout_ms: tenMinMs,
        tool_started_at: new Date(BASE - 5 * 60 * 1000).toISOString(),
      },
      claims: [claim('msg-1', 5 * 60 * 1000)],
    });
    expect(res.action).toBe('ok');
  });

  it('ignores claims with unparseable timestamps', () => {
    const res = decideStuckAction({
      now: BASE,
      heartbeatMtimeMs: BASE - 5_000,
      containerState: null,
      claims: [{ message_id: 'x', status_changed: 'not-a-date' }],
    });
    expect(res.action).toBe('ok');
  });

  it('does not kill when a non-Bash tool is in flight within the extended ceiling', () => {
    // Agent tools emit no SDK events, so the heartbeat goes stale during
    // execution. With heartbeatAge > 30 min but tool only 45 min in, we
    // should NOT kill — tool_started_at is the real liveness signal here.
    const toolStartedAt = new Date(BASE - 45 * 60 * 1000).toISOString();
    const res = decideStuckAction({
      now: BASE,
      heartbeatMtimeMs: BASE - ABSOLUTE_CEILING_MS - 1_000, // stale heartbeat
      containerState: {
        current_tool: 'Agent',
        tool_declared_timeout_ms: null,
        tool_started_at: toolStartedAt,
      },
      claims: [],
    });
    expect(res.action).toBe('ok');
  });

  it('kills when a non-Bash tool has exceeded the extended ceiling', () => {
    // If an Agent call has been running for longer than NON_BASH_TOOL_CEILING_MS
    // it is genuinely stuck, not just slow — kill it.
    const toolStartedAt = new Date(BASE - NON_BASH_TOOL_CEILING_MS - 1_000).toISOString();
    const res = decideStuckAction({
      now: BASE,
      heartbeatMtimeMs: BASE - NON_BASH_TOOL_CEILING_MS - 5_000,
      containerState: {
        current_tool: 'Agent',
        tool_declared_timeout_ms: null,
        tool_started_at: toolStartedAt,
      },
      claims: [],
    });
    expect(res.action).toBe('kill-ceiling');
  });
});

// ─────────────────────────────────────────────────────────────────────────────
// Orphan claim cleanup (regression test for the SIGKILL → claim-stuck loop)
//
// Repro of the production bug seen 2026-04-30: container A claimed message M
// (writes processing_ack row with status='processing'). Host kills A by
// absolute-ceiling. Old behavior: messages_in.M was reset to pending but
// processing_ack.M survived. On the next sweep tick, wakeContainer spawned B,
// the same-tick SLA check saw M's stale claim age (hours), and SIGKILL'd B
// before agent-runner could run clearStaleProcessingAcks(). Loop. The fix
// deletes processing_ack 'processing' rows when the host kills/cleans the
// container, breaking the loop atomically.
// ─────────────────────────────────────────────────────────────────────────────

function makeSessionDbs(): { inDb: Database.Database; outDb: Database.Database } {
  const inDb = new Database(':memory:');
  inDb.exec(`
    CREATE TABLE messages_in (
      id            TEXT PRIMARY KEY,
      seq           INTEGER UNIQUE,
      kind          TEXT NOT NULL,
      timestamp     TEXT NOT NULL,
      status        TEXT DEFAULT 'pending',
      process_after TEXT,
      recurrence    TEXT,
      series_id     TEXT,
      tries         INTEGER DEFAULT 0,
      trigger       INTEGER NOT NULL DEFAULT 1,
      platform_id   TEXT,
      channel_type  TEXT,
      thread_id     TEXT,
      content       TEXT NOT NULL
    );
  `);
  const outDb = new Database(':memory:');
  outDb.exec(`
    CREATE TABLE processing_ack (
      message_id     TEXT PRIMARY KEY,
      status         TEXT NOT NULL,
      status_changed TEXT NOT NULL
    );
  `);
  return { inDb, outDb };
}

function fakeSession(): Session {
  return {
    id: 'sess-test',
    agent_group_id: 'ag-test',
    messaging_group_id: null,
    thread_id: null,
    agent_provider: null,
    status: 'active',
    container_status: 'stopped',
    last_active: null,
    created_at: new Date().toISOString(),
  };
}

describe('deleteOrphanProcessingClaims', () => {
  it('removes only processing rows, leaves completed/failed alone', () => {
    const { outDb } = makeSessionDbs();
    const ts = new Date().toISOString();
    outDb.prepare("INSERT INTO processing_ack VALUES ('m-proc', 'processing', ?)").run(ts);
    outDb.prepare("INSERT INTO processing_ack VALUES ('m-done', 'completed', ?)").run(ts);
    outDb.prepare("INSERT INTO processing_ack VALUES ('m-fail', 'failed', ?)").run(ts);

    const removed = deleteOrphanProcessingClaims(outDb);

    expect(removed).toBe(1);
    const remaining = outDb.prepare('SELECT message_id, status FROM processing_ack ORDER BY message_id').all();
    expect(remaining).toEqual([
      { message_id: 'm-done', status: 'completed' },
      { message_id: 'm-fail', status: 'failed' },
    ]);
  });

  it('returns 0 when nothing to clear', () => {
    const { outDb } = makeSessionDbs();
    expect(deleteOrphanProcessingClaims(outDb)).toBe(0);
  });
});

describe('resetStuckProcessingRows — orphan claim cleanup', () => {
  it('deletes orphan processing_ack rows so next sweep tick does not see them', () => {
    const { inDb, outDb } = makeSessionDbs();
    const claimedAt = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); // 2h ago

    // messages_in.status stays 'pending' during processing — only the
    // container's processing_ack moves to 'processing'. See
    // src/db/schema.ts header comment on processing_ack.
    inDb
      .prepare(
        "INSERT INTO messages_in (id, seq, kind, timestamp, status, content) VALUES ('m-1', 1, 'chat', ?, 'pending', '{}')",
      )
      .run(claimedAt);
    outDb.prepare("INSERT INTO processing_ack VALUES ('m-1', 'processing', ?)").run(claimedAt);

    // Sanity: the orphan claim is what would trip claim-stuck.
    expect(getProcessingClaims(outDb)).toHaveLength(1);

    _resetStuckProcessingRowsForTesting(inDb, outDb, fakeSession(), 'absolute-ceiling');

    // Regression assertion: orphan claim is gone — next sweep tick will see
    // an empty claims list and not kill the freshly respawned container.
    expect(getProcessingClaims(outDb)).toEqual([]);

    // And the message itself was rescheduled with backoff (existing behavior).
    const row = inDb.prepare('SELECT status, tries, process_after FROM messages_in WHERE id = ?').get('m-1') as {
      status: string;
      tries: number;
      process_after: string | null;
    };
    expect(row.status).toBe('pending');
    expect(row.tries).toBe(1);
    expect(row.process_after).not.toBeNull();
  });

  it('still clears orphan claims even when the inbound message has already been retried (skip path)', () => {
    // Edge case: the inbound row was already rescheduled (process_after in
    // future), so the per-message retry loop skips it. The orphan in
    // processing_ack must still be removed — otherwise the bug remains.
    const { inDb, outDb } = makeSessionDbs();
    const claimedAt = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
    const future = new Date(Date.now() + 60_000).toISOString();

    inDb
      .prepare(
        "INSERT INTO messages_in (id, seq, kind, timestamp, status, process_after, tries, content) VALUES ('m-2', 2, 'chat', ?, 'pending', ?, 1, '{}')",
      )
      .run(claimedAt, future);
    outDb.prepare("INSERT INTO processing_ack VALUES ('m-2', 'processing', ?)").run(claimedAt);

    _resetStuckProcessingRowsForTesting(inDb, outDb, fakeSession(), 'claim-stuck');

    expect(getProcessingClaims(outDb)).toEqual([]);
    const row = inDb.prepare('SELECT tries FROM messages_in WHERE id = ?').get('m-2') as { tries: number };
    expect(row.tries).toBe(1); // not bumped, the skip path held
  });
});

describe('parseSqliteUtc', () => {
  // Regression: SQLite TIMESTAMP strings have no zone marker, but Date.parse
  // treats those as local time. On non-UTC hosts this made every claim look
  // (TZ offset) hours stale and tripped kill-claim on freshly-claimed messages.
  // The helper appends "Z" only when no marker is present, so parsing is
  // always anchored to UTC regardless of host timezone.

  const utcMs = Date.parse('2026-04-20T12:00:00.000Z');

  it('treats a SQLite-style timestamp (no zone) as UTC', () => {
    expect(parseSqliteUtc('2026-04-20 12:00:00')).toBe(utcMs);
    expect(parseSqliteUtc('2026-04-20T12:00:00')).toBe(utcMs);
    expect(parseSqliteUtc('2026-04-20T12:00:00.000')).toBe(utcMs);
  });

  it('preserves an explicit Z marker', () => {
    expect(parseSqliteUtc('2026-04-20T12:00:00.000Z')).toBe(utcMs);
    expect(parseSqliteUtc('2026-04-20T12:00:00z')).toBe(utcMs);
  });

  it('preserves an explicit numeric offset', () => {
    // 14:00+02:00 == 12:00 UTC
    expect(parseSqliteUtc('2026-04-20T14:00:00+02:00')).toBe(utcMs);
    expect(parseSqliteUtc('2026-04-20T14:00:00+0200')).toBe(utcMs);
    // 07:00-05:00 == 12:00 UTC
    expect(parseSqliteUtc('2026-04-20T07:00:00-05:00')).toBe(utcMs);
  });

  it('returns NaN for unparseable input', () => {
    expect(Number.isNaN(parseSqliteUtc('not a date'))).toBe(true);
  });

  it('does not drift across host timezones for SQLite-style input', () => {
    // The helper itself is timezone-independent because it forces UTC parsing.
    // (Verifying the regex branch — without the helper, `Date.parse` of the
    // bare string returns different values depending on the host TZ.)
    const bare = '2026-04-20T12:00:00';
    expect(parseSqliteUtc(bare)).toBe(Date.parse(bare + 'Z'));
  });
});


# Portfolio Board

One row per active project. Cards with full detail live in `projects/`.
Operating model + decisions: [PLAN.md](PLAN.md).
Stages: `research → discovery → spec → build → test → feedback → maintain`.
Guard: `review-required` (James approves merges) or `autonomous-ok` (agents proceed, report after).

**Seeded 2026-07-06 from git activity — stages and next actions are drafts until James confirms.**

| Project | Stage | Guard | Next action | Blocked on James | Last activity |
|---|---|---|---|---|---|
| [portfolio-autopilot](projects/portfolio-autopilot.md) | build | autonomous-ok | Monitor headroom log; calibration surfaces reserve suggestions after 5+ snapshots | none | 2026-07-24 |
| [qip-qien](projects/qip-qien.md) | feedback | review-required | SEV-1 fix committed (ee84254); merge sign-off needed | merge approval | 2026-07-19 |
| [congressional-trading](projects/congressional-trading.md) | test | autonomous-ok | Await threshold decision; weekly precision monitor running (Mondays 9am) | threshold decision | 2026-07-21 |
| [FindRefurbishedAppleProduct](projects/FindRefurbishedAppleProduct.md) | discovery | autonomous-ok | Confirm goal — brand new repo | confirm card | 2026-07-06 |
| [TradingBot25](projects/TradingBot25.md) | build | review-required | Confirm goal + current strategy state | confirm card | 2026-06-26 |
| [AgenticLever](projects/AgenticLever.md) | build | autonomous-ok | Confirm goal + next feature direction | confirm goal | 2026-07-20 |
| [pool-monitor](projects/pool-monitor.md) | maintain | autonomous-ok | None — steady state? confirm | confirm card | 2026-06-17 |
| [StageHand](projects/StageHand.md) | discovery | autonomous-ok | Await James answers on 5 toolchain questions; headless-browser gallery capture if Local Recs green-lit | voice, agent, posting, pricing, Local Recs y/n | 2026-07-21 |
| [MentorCore](projects/MentorCore.md) | research | autonomous-ok | Parked — no bandwidth | none | 2026-06-11 |
| [RoadSense](projects/RoadSense.md) | spec | autonomous-ok | Answer 5 launch questions → unblock implementation | auth model, city, GIS license, creator posture, metric | 2026-07-21 |
| [nanoclaw](projects/nanoclaw.md) | build | autonomous-ok | Review push() fix (d4ef752) → deploy to Studio | deploy, metric | 2026-07-20 |
| [projects-monorepo-infra](projects/projects-monorepo-infra.md) | build | review-required | Name the token-spend-avoided metric | metric decision | 2026-07-16 |
| cortex *(no card yet)* | maintain | review-required | Decide: fill Reddit OAuth creds, or drop Reddit ingest (project's own docs call the API "increasingly restrictive") | Reddit creds / keep-or-drop call | 2026-07-12 |
| CodexBar *(no card yet)* | maintain | review-required | Decide upstream-sync strategy (1056 commits behind steipete/CodexBar); scrub steipete paths/bundle-ID from docs | sync strategy | 2026-07-12 |
| CodexBarLocal *(no card yet)* | maintain | review-required | 34 uncommitted changes incl. a stray Xcode scaffold — commit, stash, or discard | commit/stash/discard call | 2026-07-12 |
| CodexBar-ESP32 *(no card yet)* | maintain | autonomous-ok | None right now — mDNS doc gap fixed today; 3 minor observations items open (untested pace-formula dup, lock-ordering) | none | 2026-07-12 |
| localsky_radar *(no card yet)* | build | review-required | `git init` + first commit (2,227 lines, zero version control); rewrite CLAUDE.md (wrongly claims unimplemented) | confirm git-init | 2026-07-12 |

## Decision queue

Questions for James, appended by Nano; answered items move to the card's Notes and are removed here.

- **nanoclaw metric:** Proposed metric = "decisions James answers per week from daily brief" (proxy for brief usefulness). Confirm or rename? (Card currently undefined.)
- **RoadSense launch questions (5):** auth model, generalization city, GIS license, creator posture, 30-day success metric. Sent via Telegram 2026-07-08.
- **congressional-trading dual-poller (2026-07-12):** `com.congresswatch.daily` launchd job confirmed loaded on *both* the Mac Mini and this Studio simultaneously — doubles LLM spend and races notification cooldowns. Project's own `agentic_plan.md` already flags this as unresolved. Which machine is authoritative?
- **qip-ask-qien SEV-1 merge (2026-07-19):** fix committed (ee84254) — admin route guards changed from `requireExpertApiUser` → `requireAdminApiUser` on 4 routes. TypeScript + lint clean. Ready to merge; needs James's sign-off to push.
- **nanoclaw push() fix deploy (2026-07-20):** Fix implemented (d4ef752), 135 tests pass. Awaiting Studio deploy. Design decisions made autonomously: (1) follow-up-turn UX = yes; (2) Claude safety net = yes; (3) tests = yes.

## Recently shipped

- **Portfolio audit** 2026-07-12 — parallel survey across 20 projects; auto-fixed 10 AGENTS.md/CLAUDE.md symmetry gaps, an mDNS doc gap (CodexBar-ESP32), a stale phase claim (MentorCore), a wrong-hostname ntfy-link bug (pool-monitor, verified via 76 passing tests), a test-isolation gap (AgenticLever, verified via 32 passing tests), and this file's own Tailscale hostname drift.
- **nanoclaw** 2026-07-08 — fix(router): mention-sticky Slack session scoping committed


# Portfolio Autopilot

- Repo: n/a — lives in Nano's scheduled tasks + config
- Goal: Quota-aware autonomous work loop that advances portfolio projects using excess LLM capacity, while preserving a hard reserve for James's interactive use. Self-adjusting over time.
- Stage: build
- Guard: autonomous-ok
- Metric: portfolio stages advanced per week at ≤ 15% of James's interactive quota consumed
- Blocked on James: none
- Next action: Monitor headroom log as post-run data accumulates; weekly calibration (Mondays 08:00 CT) will surface reserve adjustment suggestions once 5+ snapshots exist

## Design

### Three levers (all configurable)

| Lever | Default | Purpose |
|---|---|---|
| Base reserve (5h window) | 25% | Interactive buffer — scales down as reset approaches |
| Base reserve (7d window) | 15% | Interactive buffer — scales down as reset approaches |

**No per-session cap.** If there's burnable quota and work to do, use it.

### Adaptive reserve formula

```
adaptive_reserve_pct = base_reserve_pct × (resets_in_sec / window_duration_sec)
burnable_pct         = remaining_pct − adaptive_reserve_pct
```

This means:
- Start of window: full reserve held
- Mid-window: reserve shrinks proportionally
- Near reset: reserve → 0 (almost all remaining quota is burnable)

Applied to both windows independently; take the more conservative (lower burnable) as the constraint:
```
burnable = min(burnable_5h, burnable_7d)
```

Work runs only when `burnable > 0`.

### Task types + model assignment

Task type determines model and effort *regardless of quota tier* — planning always uses the best available model because the cost of a bad plan exceeds the cost of the tokens.

| Task type | Model | Effort | Notes |
|---|---|---|---|
| Plan (greenfield) | Claude Sonnet | high | Always capable model — non-negotiable |
| Red-team review | Claude Sonnet | medium | Auto-runs after every plan, before human sees it |
| Plan (continuation) | Claude Sonnet | medium | Existing direction, lower risk |
| Implementation | Claude Sonnet or Haiku | medium | Quota-tier dependent |
| Research / analysis | Gemini or Haiku | low | Bulk work, quota-saving |
| Maintenance | LM Studio or Haiku | low | Formatting, summaries, simple edits |

### Human-in-the-loop checkpoints

**Greenfield work** (new plan, new feature, new project direction):
```
Plan → Auto red-team → Package (plan + critique + open questions) → Send to James → James approves → Implementation
```
Implementation does not start until James explicitly approves. The package sent to James includes: the plan, the red-team's blind spots and risk flags, and a numbered list of open questions.

**Continuing work** (clear next action on an existing spec):
```
Implement → Report outcome → James can redirect if needed
```
No checkpoint required for `autonomous-ok` projects with a well-defined next action.

**review-required projects** (TradingBot25, qip-qien):
All changes go as PRs; James approves merges. No auto-merge ever.

### Quota tiers → implementation model routing

Planning always uses capable model (see above). Implementation routing adapts to quota:

| Burnable quota | Implementation model | Projects allowed |
|---|---|---|
| High (>50% burnable) | Claude Sonnet | P1, P2, P3 |
| Medium (20–50%) | Claude Sonnet | P1, P2 |
| Low (5–20%) | Gemini or Haiku | P1 only |
| Minimal (<5%) | LM Studio | maintenance only |
| Zero | No autonomous work | — |

### Burn-rate gate (for P2/P3)

`sustainable_pct_per_hour = 100 / window_hours`

P2/P3 work only runs when actual burn rate < sustainable pace — i.e., quota is ahead of the smooth curve. P1 work bypasses this gate (runs whenever above the reserve floor).

### Priority tiers (proposed — James to confirm)

| Project | Priority | Rationale |
|---|---|---|
| StageHand | P1 | James's priority — real estate video suite, active discovery |
| nanoclaw | P1 | Maintenance + ongoing infra work |
| AgenticLever | P2 | Business-facing, active build |
| RoadSense | P2 | Going public, active momentum |
| qip-qien | P2 | Live trial — needs progress but review-required gate limits autonomous scope |
| congressional-trading | P3 | Runs autonomously; calibration is in-flight, low need for new LLM spend |
| TradingBot25 | P3 | Deep investment already; review-required, low autonomous-work surface |

### Closed-loop auto-calibration

Track per run:
- Quota spent (from CodexBar delta before/after)
- Output produced (git commits, wiki pages, board moves)
- Did James use Claude within 2h after an autonomous run? (proxy for "left enough headroom")

Weekly review: if James-usage-after-run rate is high → reserve floor was too low → bump it up. If output/quota ratio is consistently low → narrow per-session cap. If P1 projects are stalling → loosen moderate-tier threshold.

The loop surfaces a weekly "autopilot health" note (not a full brief — one line per signal that changed).

### Config file

`/workspace/agent/autopilot-config.json` — all thresholds live here so any run can tweak them without code changes.

```json
{
  "reserve_5h_pct": 25,
  "reserve_7d_pct": 15,

  "project_priorities": {
    "StageHand": "P1",
    "nanoclaw": "P1",
    "AgenticLever": "P2",
    "RoadSense": "P2",
    "qip-qien": "P2",
    "congressional-trading": "P3",
    "TradingBot25": "P3"
  },
  "flush_threshold": 60,
  "comfortable_threshold": 40,
  "moderate_threshold": 20
}
```

## Implementation plan

1. **Config + quota reader** — `autopilot-config.json` + script that reads CodexBar endpoint and computes current tier and burn rate
2. **Routing logic** — given tier + priority + project next-action, decide: proceed / skip / use alternate model
3. **Task executor** — runs one unit of work per session, commits result, updates board
4. **Scheduled trigger** — every 30 min, runs quota check; fires task if conditions met
5. **Outcome tracker** — logs quota delta + output produced per run
6. **Weekly calibration** — reviews outcome log, suggests threshold changes

## Notes

- 2026-07-07 — James approved design. Start with intelligent defaults, evolve to closed-loop. Config file approach so thresholds are tweakable without code changes.
- 2026-07-07 — James set priorities (inverted from initial proposal): P1 = StageHand, nanoclaw; P2 = AgenticLever, RoadSense, qip-qien; P3 = congressional-trading, TradingBot25.
- 2026-07-07 — Reserve revised to adaptive formula (scales linearly to 0 as window resets). Per-session cap removed — no artificial throttle on session size.
- 2026-07-07 — Core build complete: `autopilot-config.json` (thresholds + priority map), `autopilot.mjs` (quota reader + routing logic, human-readable + JSON mode), `autopilot-trigger.mjs` (pre-script for scheduler — returns wakeAgent true/false). Scheduled task `task-1783433888329-w9msxw` runs every 30 min.
- 2026-07-08 — Outcome tracker built: `autopilot-summary.mjs` (stats by outcome/project/tier/model, recent history, --json + --last N flags). Weekly calibration built: `autopilot-calibrate.mjs` (7-day stats, sends Telegram summary). Scheduled weekly Mondays 08:00 CT.
- 2026-07-08 — Skip-lock added to `autopilot-trigger.mjs`: agent writes `autopilot-skip-lock.json` after all-blocked cycles to suppress repeated 30-min trigger wakes. Prevents wasted cycles when all projects are blocked on James.
- CodexBar endpoint: `http://host.docker.internal:7842/usage` — confirmed live and accessible from container.
- LM Studio confirmed live at `http://host.docker.internal:1234` with Gemma 4 12B + 26B models loaded.
- 2026-07-12 — Closed-loop calibration wired up. Three new components: `autopilot-headroom.mjs` (post-run quota snapshot logger), pre-run snapshot added to `autopilot-trigger.mjs`, and headroom analysis section added to `autopilot-calibrate.mjs`. Snapshots track remaining_5h/7d_pct vs reserve floor; calibration script suggests raising/lowering reserve based on 7-day sample. First post-run snapshot captured: 5h=73% remaining (adequate), 7d=92% (adequate), burnable=71%.
- 2026-07-24 — Fixed skip-lock format bug. Root cause: agent was writing `{"written_at":...}` but `autopilot-trigger.mjs` checks `lock.skip_lock_until` (line 29) — a file without that field is silently ignored, causing repeated 30-min triggers after all-blocked cycles. Fix: skip-lock now written with `skip_lock_until` set to `next_reset_at`. CLAUDE.local.md updated with correct format + bug history. No trigger script changes needed.