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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/guard-telemetry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@reddb-io/redcode": minor
"@reddb-io/redcode-core": minor
"@reddb-io/redcode-schema": minor
---

Write down every time a guard intervenes, so the thresholds can be argued from evidence

Five guards ship in 0.14.0 — the inactivity watchdog, tool deadlines, the loop guard, the step budget, the bounds on naming and compacting — and every threshold in them was chosen by argument, because there was nothing to measure. Each intervention is now recorded with which guard fired, what it acted on, and what it did, and published as a live `session.next.guard.tripped` event. `redcode debug guards` reads it back: counts per guard and action over the last week, loudest first, plus the most recent trips. An empty report says so in words, because "nothing fired" and "nothing was collected" are different answers.
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,16 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(3)
// Waited for rather than counted in frames: three frames is plenty on a quiet machine and not
// enough on a loaded CI runner, and the difference has nothing to do with what is being tested.
await until(() => calls.length > 0)
expect(calls).toEqual([[0, false]])

route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(3)
// Still one call: the offset now matches, so there is nothing new to report.
expect(calls).toEqual([[0, false]])

cleanup?.()
Expand Down Expand Up @@ -233,6 +236,17 @@ test("reconnects when the batch reports the addition before the removal", async
}
})

/** Waits for a condition across animation frames, up to a generous deadline. */
async function until(condition: () => boolean, budgetMs = 2_000) {
const deadline = Date.now() + budgetMs
while (!condition() && Date.now() < deadline) {
await frames(1)
// A macrotask between frames: the observer's callback is delivered on one, and a loop of
// nothing but animation frames can starve it.
await new Promise((resolve) => setTimeout(resolve, 1))
}
}

async function frames(count: number) {
for (let index = 0; index < count; index++) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
Expand Down
123 changes: 121 additions & 2 deletions packages/core/schema.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "f14a9b18-8207-487e-a3d3-227e629ba9ad",
"prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"],
"id": "36772f61-4ca4-48df-b27c-6aa39eb59ba1",
"prevIds": ["f14a9b18-8207-487e-a3d3-227e629ba9ad"],
"ddl": [
{
"name": "workspace",
Expand Down Expand Up @@ -60,6 +60,10 @@
"name": "session_context_epoch",
"entityType": "tables"
},
{
"name": "session_guard_trip",
"entityType": "tables"
},
{
"name": "session_input",
"entityType": "tables"
Expand Down Expand Up @@ -920,6 +924,86 @@
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "session_guard_trip"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "session_guard_trip"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "guard",
"entityType": "columns",
"table": "session_guard_trip"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "action",
"entityType": "columns",
"table": "session_guard_trip"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "subject",
"entityType": "columns",
"table": "session_guard_trip"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "detail",
"entityType": "columns",
"table": "session_guard_trip"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "session_guard_trip"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "session_guard_trip"
},
{
"type": "text",
"notNull": false,
Expand Down Expand Up @@ -1728,6 +1812,13 @@
"table": "session_context_epoch",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "session_guard_trip_pk",
"table": "session_guard_trip",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
Expand Down Expand Up @@ -1872,6 +1963,34 @@
"entityType": "indexes",
"table": "part"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_guard_trip_session_idx",
"entityType": "indexes",
"table": "session_guard_trip"
},
{
"columns": [
{
"value": "time_created",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_guard_trip_created_idx",
"entityType": "indexes",
"table": "session_guard_trip"
},
{
"columns": [
{
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/database/migration.gen.ts

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

Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"

export default {
id: "20260904202310_session_guard_trip",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`session_guard_trip\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`guard\` text NOT NULL,
\`action\` text NOT NULL,
\`subject\` text,
\`detail\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL
);
`)
yield* tx.run(`CREATE INDEX \`session_guard_trip_session_idx\` ON \`session_guard_trip\` (\`session_id\`);`)
yield* tx.run(`CREATE INDEX \`session_guard_trip_created_idx\` ON \`session_guard_trip\` (\`time_created\`);`)
})
},
} satisfies DatabaseMigration.Migration
14 changes: 14 additions & 0 deletions packages/core/src/database/schema.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,18 @@ export default {
CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`session_guard_trip\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`guard\` text NOT NULL,
\`action\` text NOT NULL,
\`subject\` text,
\`detail\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL
);
`)
yield* tx.run(`
CREATE TABLE \`session_input\` (
\`id\` text PRIMARY KEY,
Expand Down Expand Up @@ -246,6 +258,8 @@ export default {
)
yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`)
yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`)
yield* tx.run(`CREATE INDEX \`session_guard_trip_session_idx\` ON \`session_guard_trip\` (\`session_id\`);`)
yield* tx.run(`CREATE INDEX \`session_guard_trip_created_idx\` ON \`session_guard_trip\` (\`time_created\`);`)
yield* tx.run(
`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`,
)
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/session/message-updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
)
},
"session.next.prompt.admitted": () => Effect.void,
// Diagnostics about the run, not part of the conversation: counted elsewhere, nothing to
// project into a message here.
"session.next.guard.tripped": () => Effect.void,
"session.next.context.updated": (event) =>
adapter.appendMessage(
SessionMessage.System.make({
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/session/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,34 @@ export const SessionMessageTable = sqliteTable(
],
)

/**
* Every time one of the turn's guards intervened.
*
* Diagnostics, deliberately not a durable session event: this is how we find out whether the
* watchdog, the tool deadlines, the loop guard and the step budget ever fire in real use, and
* whether they fire when they should not. Thresholds were chosen by argument; this is what lets
* them be chosen by evidence. Deleting the table loses nothing but the record.
*/
export const SessionGuardTripTable = sqliteTable(
"session_guard_trip",
{
id: text().primaryKey(),
session_id: text().$type<SessionSchema.ID>().notNull(),
/** stall | tool_timeout | loop | steps | aux */
guard: text().notNull(),
/** warn | correct | stop */
action: text().notNull(),
/** The tool, the phase, or whatever names the thing that tripped it. */
subject: text(),
detail: text().notNull(),
...Timestamps,
},
(table) => [
index("session_guard_trip_session_idx").on(table.session_id),
index("session_guard_trip_created_idx").on(table.time_created),
],
)

export const SessionInputTable = sqliteTable(
"session_input",
{
Expand Down
48 changes: 48 additions & 0 deletions packages/redcode/src/cli/cmd/debug/guards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Effect } from "effect"
import { SessionGuardLog } from "@/session/guard-log"
import { effectCmd } from "../../effect-cmd"

const DAY_MS = 86_400_000

/**
* What the turn's guards actually did.
*
* Every threshold in them was argued into place — ten minutes for a tool, three identical calls
* for a loop, two steps of grace before the wall. This is the command that turns the next argument
* into a reading: how often each one fired, on what, and whether it was right.
*/
export const GuardsCommand = effectCmd({
command: "guards",
describe: "how often the turn guards intervened, and on what",
builder: (yargs) =>
yargs
.option("days", { type: "number", default: 7, description: "how far back to look" })
.option("limit", { type: "number", default: 20, description: "how many recent trips to print" }),
handler: Effect.fn("Cli.debug.guards")(function* (args) {
const since = Date.now() - Math.max(0, args.days) * DAY_MS
const summary = yield* SessionGuardLog.Service.use((svc) => svc.summary({ since }))
const recent = yield* SessionGuardLog.Service.use((svc) => svc.recent({ since, limit: args.limit }))

if (summary.length === 0) {
console.log(`No guard fired in the last ${args.days} day(s).`)
// Said plainly: an empty table reads as "no data collected", which is a different thing.
console.log("That means every turn finished on its own, or this build has not run since they landed.")
return
}

console.log(`Guards that fired in the last ${args.days} day(s):\n`)
const width = Math.max(...summary.map((row) => row.guard.length))
for (const row of summary) {
console.log(` ${row.guard.padEnd(width)} ${row.action.padEnd(7)} ${row.count}`)
}

if (recent.length === 0) return
console.log(`\nMost recent:\n`)
for (const entry of recent) {
const when = new Date(entry.at).toISOString().replace("T", " ").slice(0, 19)
const subject = entry.subject ? ` ${entry.subject}` : ""
console.log(` ${when} ${entry.guard}/${entry.action}${subject}`)
console.log(` ${entry.detail.split("\n")[0]}`)
}
}),
})
2 changes: 2 additions & 0 deletions packages/redcode/src/cli/cmd/debug/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Duration, Effect } from "effect"
import { effectCmd } from "../../effect-cmd"
import { cmd } from "../cmd"
import { ConfigCommand } from "./config"
import { GuardsCommand } from "./guards"
import { FileCommand } from "./file"
import { LSPCommand } from "./lsp"
import { RipgrepCommand } from "./ripgrep"
Expand All @@ -23,6 +24,7 @@ export const DebugCommand = cmd({
builder: (yargs) =>
yargs
.command(ConfigCommand)
.command(GuardsCommand)
.command(LSPCommand)
.command(RipgrepCommand)
.command(FileCommand)
Expand Down
2 changes: 2 additions & 0 deletions packages/redcode/src/effect/app-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { SessionCompaction } from "@/session/compaction"
import { SessionRevert } from "@/session/revert"
import { SessionSummary } from "@/session/summary"
import { SessionPrompt } from "@/session/prompt"
import { SessionGuardLog } from "@/session/guard-log"
import { Instruction } from "@/session/instruction"
import { LLM } from "@/session/llm"
import { LSP } from "@/lsp/lsp"
Expand Down Expand Up @@ -88,6 +89,7 @@ export const AppLayer = AppNodeBuilderV1.build(
SessionRevert.node,
SessionSummary.node,
SessionPrompt.node,
SessionGuardLog.node,
Instruction.node,
LLM.node,
LSP.node,
Expand Down
Loading
Loading