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
12 changes: 12 additions & 0 deletions .claude/rules/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,18 @@ layer per capability," never "a seed per thing":
- **Manual-only registry** — OS-bound checks (hotkey delivery, real
clipboard, tray) listed explicitly with reasons, never silently
absent (see goal 0010's enforcement).
- **Dev-loop timing checks** — a non-seed instance of the same manual-
only discipline, outside goal 0010's seed/NodeType registry (that
machinery is keyed to seeded artifacts; this isn't one). Goal 0029's
BuildIdentityBadge third state (amber `DEV · go-stale`) depends on a
real `wails3 dev` rebuild wedging or running slow — CI has no live
file watcher or real Go recompile-and-relaunch cycle to reproduce
that timing deterministically. The pure comparison logic
(`isGoSourceStale`, `frontend/src/app/goLiveness.ts`) is unit-tested
directly (`goLiveness.test.ts`); the full live behavior — an actually
wedged watcher flipping the badge amber in a real window — stays a
manual desktop-mode check (`.claude/skills/run-mill`), named here
rather than silently absent.

From the UX point of view the seed layer stays privileged — it's the
one a human can SEE working — but correctness under change belongs to
Expand Down
20 changes: 20 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,26 @@ tasks:
# always begins from exactly one live instance. `|| true`: a clean
# start (no orphan) is the normal case, not an error.
- pkill -f "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.APP_NAME}}" || true
# Same failure mode, the vite side (goal 0029, hit live tonight):
# a supervisor killed via SIGHUP can leave the vite dev server
# itself still bound to VITE_PORT (strictPort: true in
# vite.config.ts), so the NEXT `wails3 dev` fails to bind instead
# of just starting -- kill whatever's still listening first.
- lsof -ti :{{.VITE_PORT}} | xargs kill -9 2>/dev/null || true
# Disk-space warning (goal 0029, hit live tonight): a Go rebuild
# can fail or wedge SILENTLY when the disk fills up mid-link, and
# the build-identity badge has no way to tell "wedged" apart from
# "idle" from the outside -- so warn loudly up front instead.
# Non-blocking by design (never refuse to start the dev loop over
# this) -- df -Pk's portable single-line-per-filesystem format
# avoids the wrapped-output parsing footgun plain `df` has on
# long device paths.
- |
FREE_KB=$(df -Pk . | awk 'NR==2 {print $4}')
if [ -n "$FREE_KB" ] && [ "$FREE_KB" -lt 2097152 ] 2>/dev/null; then
FREE_GB=$(( FREE_KB / 1024 / 1024 ))
echo "⚠️ Disk space low (~${FREE_GB}GB free) -- Go rebuilds can fail or wedge silently, and BuildIdentityBadge's go-stale detection can't tell that apart from a healthy idle watcher. Run 'go clean -cache' to free space."
fi
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}

setup:docker:
Expand Down
24 changes: 24 additions & 0 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -3933,6 +3933,30 @@ recorded as a real design input (`OPEN`), never silently dropped.
moves cleanly from badge-DETECTION (which couldn't distinguish
"committed forward" from "orphaned binary" anyway) to Taskfile
PREVENTION (the sweep above) — prevention over false-alarm.
**Update (goal 0029, 2026-08-12): a third badge state closes the
remaining honesty gap — DEV·live proved the FRONTEND was live, never
the GO side.** Two real incidents in one night: a disk-full
`wails3 dev` rebuild wedged silently with the badge still green, and
separately a slow watcher cycle left the running binary 15 commits
behind while the badge stayed green — the owner debugged a working
Settings feature as broken because of it. Amber **`DEV · go-stale`**
(tooltip/label: "Go changes not yet in this binary — restart task
dev") now renders whenever `internal/**/*.go`'s newest mtime has
outlived `BuildInfo.BuiltAt` (the running binary's own executable
mtime, `settingsservice_buildinfo.go`) by more than a 30s grace
window — long enough to absorb a normal save-triggered rebuild
(including the ~20s bindings-regen path) without flapping. The
comparison input is served by a vite dev-only middleware computing
the mtime AT REQUEST TIME (`vite.config.ts`'s `goLivenessPlugin`,
`/__mill/go-source-mtime`) — chosen over a task-dev-written heartbeat
file (the goal's other candidate) because it needs no new watcher
process of its own to itself go stale. Deliberately Go-source-mtime,
never git HEAD, repeating goal 0019's own lesson: a docs-only or
frontend-only commit never touches a working-tree file's mtime, so it
can't move this comparison. Paired dev-loop guards from the same
incidents: `task dev`'s start sweep also clears an orphaned vite-port
listener (`lsof -ti :9245`), and a non-blocking pre-start disk check
warns (never blocks) below 2GB free, naming `go clean -cache`.

## 9.5 Platform kernel & extension contract

Expand Down
32 changes: 0 additions & 32 deletions docs/goals/0029-dev-liveness-honesty.md

This file was deleted.

17 changes: 13 additions & 4 deletions docs/goals/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,19 @@ this pipeline and on this code)**
verdict recorded in SPEC §3; both literal `/Users/ali` paths
de-literalized. Second lint pass (gocritic/prealloc/contextcheck/
sqlclosecheck) named as explicit future work, not done here.
7. [ ] [0029 — Dev-liveness honesty](0029-dev-liveness-honesty.md) —
the DEV·live badge's Go-liveness blind spot, now having claimed a
second scalp (ADR-0035's Consequences note); a third badge state
(amber DEV·go-stale) not yet built.
7. [x] [0029 — Dev-liveness honesty](archive/0029-dev-liveness-honesty.md)
— DELIVERED 2026-08-12: third badge state amber `DEV · go-stale`
(`frontend/src/app/goLiveness.ts` + `BuildIdentityBadge.tsx`),
comparing `BuildInfo.BuiltAt` (the running binary's own executable
mtime) against the newest `internal/**/*.go` mtime served by a
vite dev-only middleware (`vite.config.ts`'s `goLivenessPlugin`) —
chosen over a task-dev-heartbeat-file candidate since it needs no
new watcher process; deliberately Go-source-mtime, never git HEAD,
so it can't repeat goal 0019's false-alarm. `task dev`'s start
sweep also clears an orphaned vite port and warns (non-blocking)
below 2GB free disk. Pure comparison unit-tested
(`goLiveness.test.ts`); the full live-wedge behavior entered
`.claude/rules/testing.md`'s manual-only note.
8. [x] [0030 — Node standard](archive/0030-node-standard.md) —
DELIVERED 2026-08-12: `.claude/rules/node-standard.md` (8-item
checklist, citing n8n's community-node/UX/error-handling
Expand Down
69 changes: 69 additions & 0 deletions docs/goals/archive/0029-dev-liveness-honesty.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# 0029 — Dev-liveness honesty: the DEV·live badge must not vouch for a dead watcher

## Goal
The green DEV·live badge claimed liveness twice while the Go binary
was stale (once wedged by disk-full, once 15 commits behind — the
owner's "can't toggle" Settings report was this, not a bug). Goal
0019's "DEV wins unconditionally" traded away Go-rebuild honesty for
no-false-alarms; both scalps prove the trade needs a third state.

## Plan
1. [x] Research-first (small): the cheapest honest Go-liveness signal
in dev — candidates: the binary self-reports its build time via
GetBuildInfo (exists) and the frontend compares against the
NEWEST mtime of internal/**/*.go at bundle-serve time (vite can
compute at request time in dev middleware); or task dev writes a
heartbeat file the binary's own watcher-restart updates. Must NOT
false-alarm on docs-only commits (goal 0019's original trap) —
compare against Go-source state only, never git HEAD.
**Chosen: candidate 1 (binary mtime vs. vite-middleware mtime)** —
the binary already self-reports (`GetBuildInfo` existed; added
`BuiltAt`, its own executable's mtime, since `vcs.time` is pinned to
the last COMMIT and never moves across an uncommitted `wails3 dev`
relink). The heartbeat-file candidate would need teaching a
third-party supervisor (`wails3 dev`/`atterpac/refresh`) to write a
file on rebuild — no such hook exists. Two numbers, one comparison,
no new watcher process to itself go stale.
2. [x] A third badge state: green DEV·live (both live), amber
DEV·go-stale ("Go changes not yet in this binary — the watcher
may be wedged; restart task dev"), red STALE unchanged. The amber
text names the remedy.
Built: `frontend/src/app/goLiveness.ts` (`isGoSourceStale`,
`useGoSourceStale`, a 30s grace window absorbing a normal
save-triggered rebuild incl. the ~20s bindings-regen path),
`frontend/src/app/BuildIdentityBadge.tsx` (amber `Label
variant="attention"`, label + title both name the remedy),
`frontend/vite.config.ts` (`goLivenessPlugin`, dev-only
`/__mill/go-source-mtime` middleware, request-time mtime walk of
`internal/**/*.go` — never git HEAD, so a docs/frontend-only commit
can't move it), `internal/services/settingssvc/settingsservice_buildinfo.go`
(`BuildInfo.BuiltAt`).
3. [x] Dev-loop guards from tonight's incidents: the task dev
start-sweep also clears the orphaned vite port (lsof -ti :9245);
a pre-build disk-space check (< 2GB free → loud warning naming
`go clean -cache`, the recurring silent killer).
Built in `Taskfile.yml`'s `dev` task, both non-blocking. Verified
live against the actual tight-disk conditions this session ran
under (~1.8GB free triggered the real warning text).
4. [x] E2e where testable; manual-only registry for the rest.
The pure comparison (`isGoSourceStale`) is unit-tested directly
(`frontend/src/app/goLiveness.test.ts`, 6 cases incl. the
docs-commit-never-moves-it case). The full live behavior (an
actually wedged watcher flipping a real window's badge amber) is
impractical to reproduce deterministically in CI — entered in
`.claude/rules/testing.md`'s manual-only note with its reason,
pointing at `.claude/skills/run-mill` for the manual check.

## Acceptance
A wedged watcher shows amber within one poll interval while docs-only
commits stay green; the owner never again debugs a working feature
against a stale binary.

**Met 2026-08-12.** The comparison polls every 5s
(`GO_LIVENESS_POLL_MS`) so a genuinely wedged watcher (source mtime
outliving `BuiltAt` past the 30s grace) surfaces amber within one poll
interval of that threshold; the comparison only ever reads
`internal/**/*.go` mtimes (never git HEAD, never the frontend tree), so
a docs-only or frontend-only commit structurally cannot move it —
covered by `goLiveness.test.ts`'s dedicated case. `docs/SPEC.md` §3.8's
build-identity entry documents the third state and mechanism choice.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT

export type {
Changed
} from "./models.js";
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT

/**
* Changed is the live-sync event payload: which kind of entity changed
* (e.g. "workflow", "request", "list", "mcpserver", "decision",
* "execenv", "guardrail-rule", "run") and its ID.
*/
export interface Changed {
"entity": string;
"id": string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// This file is automatically generated. DO NOT EDIT

export type {
DataChanged,
MCPWriteActivity,
MCPWriteRequest,
MCPWriteResolved
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,6 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT

/**
* DataChanged is the live-sync event (docs/adr/0025): emitted after
* any MCP-driven mutation so an open Mill window refreshes what the
* LLM just changed -- §1's what-you-see-is-what-I-see thesis running
* in both directions.
*/
export interface DataChanged {
"entity": string;
"id": string;
}

/**
* MCPWriteActivity is pushed for a resolved (denied/cancelled/expired,
* or approved-but-failed) MCP write so it's no longer traceless
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,21 @@ export interface BuildInfo {
* genuinely knows.
*/
"Server": boolean;

/**
* BuiltAt is the unix-millis mtime of THIS PROCESS'S OWN executable
* file -- when it was actually linked, not when its source was last
* committed. Deliberately not vcs.time (goal 0029, dev-liveness
* honesty): vcs.time reflects the last COMMIT's timestamp, which
* stays frozen across every `wails3 dev` relink of an uncommitted
* change (vcs.modified just goes true) -- exactly the case a wedged
* or slow rebuild watcher needs distinguished from "healthy." A
* binary's own mtime moves on every real relink regardless of git
* state, so the frontend can compare it against the newest mtime of
* internal/** /*.go (vite's dev-only middleware, vite.config.ts) to
* tell "rebuilt after this save" from "still running the old one."
*/
"BuiltAt": number;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
// @ts-ignore: Unused imports
import type { Events } from "@wailsio/runtime";

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import type * as dataevent$0 from "../../../../alicoding/mill/internal/services/dataevent/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import type * as executionsvc$0 from "../../../../alicoding/mill/internal/services/executionsvc/models.js";
Expand All @@ -22,7 +25,7 @@ declare module "@wailsio/runtime" {
"hotkey-activity": triggersvc$0.HotkeyActivity;
"mcp-write-activity": mcpsvc$0.MCPWriteActivity;
"mcp-write-approval": mcpsvc$0.MCPWriteRequest;
"mill-data-changed": mcpsvc$0.DataChanged;
"mill-data-changed": dataevent$0.Changed;
"mill-navigate": string;
"time": string;
}
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/app/BuildIdentityBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Label } from '@primer/react'
import { SettingsService } from '../shared/bindings'
import type { BuildInfo } from '../shared/bindings'
import { useGoSourceStale } from './goLiveness'
import styles from './App.module.css'

// Extracted from App.tsx along the 500-line seam (goal 0019). ONE rule
Expand All @@ -25,13 +26,21 @@ import styles from './App.module.css'
// by an old binary -- docs/SPEC.md §3.8).
const isDevBuild = import.meta.env.DEV

// Third badge state (goal 0029, dev-liveness honesty): amber
// `DEV · go-stale` when Go source has moved since this binary was
// built but the rebuild hasn't landed yet -- the comparison logic and
// its own reasoning live in ./goLiveness (pulled out so it can be
// unit-tested without dragging @primer/react's CSS into Vitest).
export function BuildIdentityBadge({ buildInfo }: { buildInfo: BuildInfo | null }) {
// Go's own build tag (BuildInfo.Server), not a window-global sniff --
// `'_wails' in window` is true in server-mode browser tabs too (the
// runtime injects it everywhere), which silently broke this badge's
// INSTALLED/SERVER split until goal 0021's dogfooding caught it.
const isNativeWebview = buildInfo != null && !buildInfo.Server
const binaryHead = buildInfo?.Revision ? buildInfo.Revision.slice(0, 7) : ''
// Called unconditionally (rules of hooks) -- it no-ops outside a dev
// build or before buildInfo has arrived, per its own guard.
const goSourceStale = useGoSourceStale(buildInfo?.BuiltAt)

// DEV wins over the stale comparison, deliberately (fixed 2026-08-11
// after it false-alarmed on every commit): under `task dev`, vite
Expand All @@ -45,6 +54,21 @@ export function BuildIdentityBadge({ buildInfo }: { buildInfo: BuildInfo | null
// commit as the binary. Dev-orphan windows are handled by prevention
// now (the Taskfile pkill sweep, SPEC §3.8), not by this badge.
if (isDevBuild) {
// Third state (goal 0029): Go source has moved since this binary
// was linked and no rebuild has landed within the grace window --
// named remedy in both the visible label and the tooltip, since
// amber (unlike green/red) isn't self-explanatory at a glance.
if (goSourceStale) {
return (
<Label
variant="attention" size="small" className={styles.devRibbon}
data-testid="dev-go-stale-badge"
title="Go changes not yet in this binary — restart task dev"
>
DEV · go-stale
</Label>
)
}
return (
<Label variant="success" size="small" className={styles.devRibbon} data-testid="dev-build-badge">
DEV · live
Expand Down
42 changes: 42 additions & 0 deletions frontend/src/app/goLiveness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { isGoSourceStale } from './goLiveness'

// Goal 0029: the full dev-liveness behavior (a real `wails3 dev`
// rebuild wedging, the badge actually turning amber in a live window)
// is impractical to reproduce deterministically in CI -- see
// .claude/rules/testing.md's manual-only note for this feature. This
// covers the one piece that IS pure logic: given two timestamps, what
// does the comparison decide. Everything around it (the fetch poll,
// BuildInfo.BuiltAt, the vite middleware) is plumbing feeding these
// same two numbers into the same function.
describe('isGoSourceStale', () => {
it('is not stale when Go source is older than the binary', () => {
expect(isGoSourceStale(1_000, 2_000)).toBe(false)
})

it('is not stale when Go source moved after the binary but within the grace window', () => {
expect(isGoSourceStale(2_000, 1_000, 5_000)).toBe(false)
})

it('is not stale exactly at the grace boundary', () => {
expect(isGoSourceStale(6_000, 1_000, 5_000)).toBe(false)
})

it('is stale once Go source outlives the binary by more than the grace window', () => {
expect(isGoSourceStale(6_001, 1_000, 5_000)).toBe(true)
})

it('is stale for a Go source change long after the binary was built (the wedged-watcher case)', () => {
expect(isGoSourceStale(10_000_000, 1_000, 30_000)).toBe(true)
})

it('never false-alarms on a docs/frontend-only commit -- Go source mtime unchanged', () => {
// The comparison only ever sees internal/**/*.go mtimes (vite's
// dev middleware, vite.config.ts); a docs or frontend-only commit
// never advances sourceMtimeMs, so this stays false regardless of
// how much time elapses.
const builtAtMs = 1_000
const unchangedSourceMtimeMs = 500 // older than the binary itself
expect(isGoSourceStale(unchangedSourceMtimeMs, builtAtMs)).toBe(false)
})
})
Loading
Loading