diff --git a/.github/workflows/deploy-gormes-www.yml b/.github/workflows/deploy-gormes-www.yml new file mode 100644 index 000000000..8f647ff26 --- /dev/null +++ b/.github/workflows/deploy-gormes-www.yml @@ -0,0 +1,113 @@ +name: Deploy gormes.ai + +on: + push: + branches: [main] + paths: + - 'www.gormes.ai/**' + - 'benchmarks.json' + - 'docs/content/building-gormes/architecture_plan/progress.json' + - 'scripts/install.sh' + - 'scripts/install.ps1' + - 'scripts/install.cmd' + - '.github/workflows/deploy-gormes-www.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: gormes-www-pages + cancel-in-progress: true + +jobs: + deploy: + if: github.repository == 'TrebuchetDynamics/gormes-agent' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + cache-dependency-path: www.gormes.ai/go.sum + + - name: Refresh embedded data from canonical sources + # Force-copy benchmarks.json + progress.json into the embed + # directory. The Makefile does the same, but its mtime-based + # rule no-ops on fresh CI checkouts where both files share + # the same timestamp — so we copy unconditionally here. + working-directory: www.gormes.ai + run: | + mkdir -p internal/site/data + cp ../benchmarks.json internal/site/data/benchmarks.json + cp ../docs/content/building-gormes/architecture_plan/progress.json internal/site/data/progress.json + + - name: Build www-gormes binary + working-directory: www.gormes.ai + run: make build + + - name: Export static site + working-directory: www.gormes.ai + run: ./bin/www-gormes export --out dist + + - name: Verify build artifacts + working-directory: www.gormes.ai + run: | + test -f dist/index.html + test -f dist/install.sh + test -f dist/install.ps1 + test -f dist/install.cmd + test -f dist/static/site.css + test -f dist/static/favicon.ico + test -f dist/static/social-card.png + + - name: Verify homepage content + working-directory: www.gormes.ai + run: | + grep -F "One Go Binary. No Python. No Drift." dist/index.html >/dev/null + grep -F 'href="/static/favicon.ico"' dist/index.html >/dev/null + grep -F 'property="og:image" content="https://gormes.ai/static/social-card.png"' dist/index.html >/dev/null + ! grep -F "Same Hermes Brain" dist/index.html >/dev/null + ! grep -F "Why a Go layer matters" dist/index.html >/dev/null + + - name: Ensure Pages project exists + # Pages project is expected to already exist (bound to gormes.ai). + # This step is idempotent — wrangler returns 409 if the project + # is already there; continue-on-error keeps the workflow healthy. + uses: cloudflare/wrangler-action@v3 + continue-on-error: true + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages project create gormes-www --production-branch=main + + - name: Deploy to Cloudflare Pages + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy www.gormes.ai/dist --project-name=gormes-www --branch=main --commit-dirty=true + + - name: Attach gormes.ai + env: + CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -u + for domain in gormes.ai www.gormes.ai; do + echo "attaching ${domain}" + response=$(curl -sS -o /tmp/cf-resp.json -w "%{http_code}" -X POST \ + "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/pages/projects/gormes-www/domains" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"name\":\"${domain}\"}") + case "$response" in + 200|201) echo "attached" ;; + 409|400) echo "already attached (status $response); skipping" ;; + *) echo "unexpected status $response"; cat /tmp/cf-resp.json ;; + esac + done diff --git a/docs/content/building-gormes/architecture_plan/progress.json b/docs/content/building-gormes/architecture_plan/progress.json index 0271f2259..41b7deb9c 100644 --- a/docs/content/building-gormes/architecture_plan/progress.json +++ b/docs/content/building-gormes/architecture_plan/progress.json @@ -453,10 +453,10 @@ }, { "name": "Non-editable gateway progress/commentary send fallback", - "status": "planned", + "status": "complete", "priority": "P3", "contract": "Channels without placeholder/edit capabilities receive progress-safe interim or final assistant messages through the plain Send path without EditMessage calls", - "contract_status": "fixture_ready", + "contract_status": "validated", "slice_size": "small", "execution_owner": "gateway", "trust_class": [ @@ -485,7 +485,7 @@ "The same fixture proves no SendPlaceholder or EditMessage method is required for non-editable channels.", "Editable channel streaming/coalescing tests keep their existing placeholder/edit behavior." ], - "note": "TDD: port the upstream f731c2c2 BlueBubbles quick-reply guard as a Go capability fixture: non-editable adapters should never be forced through the edit-message coalescer just to surface safe interim/commentary text.", + "note": "TDD landed: manager outbound tests now exercise a Channel-only fake from an originating inbound event, proving interim/commentary and terminal assistant output use the plain Send path with the original chat target while editable channels keep placeholder/edit coalescing.", "write_scope": [ "internal/gateway/manager.go", "internal/gateway/manager_test.go", diff --git a/internal/gateway/fake_test.go b/internal/gateway/fake_test.go index d10306a5e..ff2b05556 100644 --- a/internal/gateway/fake_test.go +++ b/internal/gateway/fake_test.go @@ -36,6 +36,60 @@ func newFakeChannel(name string) *fakeChannel { } } +type channelOnlyFake struct { + name string + inbox chan<- InboundEvent + started chan struct{} + + mu sync.Mutex + sent []fakeSent + nextMsgID int +} + +func newChannelOnlyFake(name string) *channelOnlyFake { + return &channelOnlyFake{ + name: name, + started: make(chan struct{}), + nextMsgID: 2000, + } +} + +func (f *channelOnlyFake) Name() string { return f.name } + +func (f *channelOnlyFake) Run(ctx context.Context, inbox chan<- InboundEvent) error { + f.mu.Lock() + f.inbox = inbox + f.mu.Unlock() + close(f.started) + <-ctx.Done() + return nil +} + +func (f *channelOnlyFake) Send(_ context.Context, chatID, text string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + id := strconv.Itoa(f.nextMsgID) + f.nextMsgID++ + f.sent = append(f.sent, fakeSent{ChatID: chatID, Text: text, MsgID: id}) + return id, nil +} + +func (f *channelOnlyFake) pushInbound(e InboundEvent) { + <-f.started + f.mu.Lock() + in := f.inbox + f.mu.Unlock() + in <- e +} + +func (f *channelOnlyFake) sentSnapshot() []fakeSent { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]fakeSent, len(f.sent)) + copy(out, f.sent) + return out +} + func (f *fakeChannel) Name() string { return f.name } func (f *fakeChannel) Run(ctx context.Context, inbox chan<- InboundEvent) error { diff --git a/internal/gateway/manager.go b/internal/gateway/manager.go index 8a2755edb..107c5f1f7 100644 --- a/internal/gateway/manager.go +++ b/internal/gateway/manager.go @@ -442,8 +442,7 @@ func (m *Manager) dispatchFrame(ctx context.Context, f kernel.RenderFrame, co ** } pe, ok := ch.(placeholderEditor) if !ok { - m.sendFinalNoStream(ctx, ch, f, chatID) - if f.Phase == kernel.PhaseIdle || f.Phase == kernel.PhaseFailed || f.Phase == kernel.PhaseCancelling { + if m.sendNoEdit(ctx, ch, f, chatID) { m.drainNextFollowUp(ctx) } return @@ -488,13 +487,20 @@ func (m *Manager) dispatchFrame(ctx context.Context, f kernel.RenderFrame, co ** } } -func (m *Manager) sendFinalNoStream(ctx context.Context, ch Channel, f kernel.RenderFrame, chatID string) { +func (m *Manager) sendNoEdit(ctx context.Context, ch Channel, f kernel.RenderFrame, chatID string) bool { switch f.Phase { case kernel.PhaseIdle: _, _ = m.sendWithHooks(ctx, ch, chatID, m.formatFinal(ch.Name(), f)) + return true case kernel.PhaseFailed, kernel.PhaseCancelling: _, _ = m.sendWithHooks(ctx, ch, chatID, m.formatError(ch.Name(), f)) + return true + case kernel.PhaseConnecting, kernel.PhaseStreaming, kernel.PhaseReconnecting, kernel.PhaseFinalizing: + if text := m.formatStream(ch.Name(), f); text != "" { + _, _ = m.sendWithHooks(ctx, ch, chatID, text) + } } + return false } func (m *Manager) sendWithHooks(ctx context.Context, ch Channel, chatID, text string) (string, error) { diff --git a/internal/gateway/manager_test.go b/internal/gateway/manager_test.go index 381e2edd2..55e3e0789 100644 --- a/internal/gateway/manager_test.go +++ b/internal/gateway/manager_test.go @@ -334,6 +334,70 @@ func TestManager_Outbound_StreamsToPinnedChannel(t *testing.T) { }) } +func TestManager_Outbound_NonEditableChannelUsesPlainSendForInterimAndFinal(t *testing.T) { + ch := newChannelOnlyFake("plainchat") + if _, ok := any(ch).(placeholderEditor); ok { + t.Fatal("channel-only fixture unexpectedly implements placeholder editing") + } + if _, ok := any(ch).(PlaceholderCapable); ok { + t.Fatal("channel-only fixture unexpectedly implements SendPlaceholder") + } + if _, ok := any(ch).(MessageEditor); ok { + t.Fatal("channel-only fixture unexpectedly implements EditMessage") + } + + frames := make(chan kernel.RenderFrame, 8) + fk := &fakeKernel{} + + m := NewManagerWithSubmitter(ManagerConfig{ + AllowedChats: map[string]string{"plainchat": "thread-42"}, + CoalesceMs: 10, + }, fk, slog.Default()) + m.setRenderChan(frames) + if err := m.Register(ch); err != nil { + t.Fatalf("Register: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { _ = m.Run(ctx) }() + + ch.pushInbound(InboundEvent{ + Platform: "plainchat", ChatID: "thread-42", MsgID: "origin-msg", + Kind: EventSubmit, Text: "hi", + }) + waitFor(t, 200*time.Millisecond, func() bool { + return len(fk.submitsSnapshot()) == 1 + }) + + frames <- kernel.RenderFrame{ + Phase: kernel.PhaseStreaming, + DraftText: "I'll inspect the repo first.", + } + frames <- kernel.RenderFrame{ + Phase: kernel.PhaseIdle, + History: []hermes.Message{ + {Role: "user", Content: "hi"}, + {Role: "assistant", Content: "done"}, + }, + } + + waitFor(t, 500*time.Millisecond, func() bool { + return len(ch.sentSnapshot()) == 2 + }) + + got := ch.sentSnapshot() + wantTexts := []string{"I'll inspect the repo first.", "done"} + for i, want := range wantTexts { + if got[i].ChatID != "thread-42" { + t.Fatalf("sent[%d].ChatID = %q, want original chat target %q", i, got[i].ChatID, "thread-42") + } + if got[i].Text != want { + t.Fatalf("sent[%d].Text = %q, want %q; sends=%#v", i, got[i].Text, want, got) + } + } +} + func TestManager_Outbound_FinalFrameClearsTurn(t *testing.T) { tg := newFakeChannel("telegram") frames := make(chan kernel.RenderFrame, 8) diff --git a/internal/progress/health.go b/internal/progress/health.go new file mode 100644 index 000000000..9816dced0 --- /dev/null +++ b/internal/progress/health.go @@ -0,0 +1,45 @@ +package progress + +// RowHealth is execution-history metadata about one progress.json item. +// Owned by autoloop. The planner READS it to prioritize repairs and MUST +// preserve any unknown fields verbatim across regenerations. +type RowHealth struct { + AttemptCount int `json:"attempt_count,omitempty"` + ConsecutiveFailures int `json:"consecutive_failures,omitempty"` + LastAttempt string `json:"last_attempt,omitempty"` + LastSuccess string `json:"last_success,omitempty"` + LastFailure *FailureSummary `json:"last_failure,omitempty"` + BackendsTried []string `json:"backends_tried,omitempty"` + Quarantine *Quarantine `json:"quarantine,omitempty"` +} + +// FailureSummary is autoloop's classification of a worker outcome. +type FailureSummary struct { + RunID string `json:"run_id"` + Category FailureCategory `json:"category"` + Backend string `json:"backend,omitempty"` + StderrTail string `json:"stderr_tail,omitempty"` +} + +// FailureCategory is the closed set of failure classifications autoloop emits. +type FailureCategory string + +const ( + FailureWorkerError FailureCategory = "worker_error" + FailureReportValidation FailureCategory = "report_validation_failed" + FailureProgressSummary FailureCategory = "progress_summary_failed" + FailureTimeout FailureCategory = "timeout" + FailureBackendDegraded FailureCategory = "backend_degraded" +) + +// Quarantine is set when ConsecutiveFailures crosses QUARANTINE_THRESHOLD. +// Cleared when (a) a future run succeeds on the row, (b) the row's spec hash +// changes (planner reshape detected), or (c) a human deletes the block. +type Quarantine struct { + Reason string `json:"reason"` + Since string `json:"since"` + AfterRunID string `json:"after_run_id"` + Threshold int `json:"threshold"` + SpecHash string `json:"spec_hash"` + LastCategory FailureCategory `json:"last_category"` +} diff --git a/internal/progress/health_test.go b/internal/progress/health_test.go new file mode 100644 index 000000000..c5fdeac9f --- /dev/null +++ b/internal/progress/health_test.go @@ -0,0 +1,72 @@ +package progress + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestRowHealth_RoundTrip(t *testing.T) { + row := &RowHealth{ + AttemptCount: 5, + ConsecutiveFailures: 3, + LastAttempt: "2026-04-24T12:00:00Z", + LastSuccess: "2026-04-23T08:00:00Z", + LastFailure: &FailureSummary{ + RunID: "20260424T120000Z-1234-001", + Category: FailureReportValidation, + Backend: "codexu", + StderrTail: "tests failed", + }, + BackendsTried: []string{"codexu", "claudeu"}, + Quarantine: &Quarantine{ + Reason: "auto: 3 consecutive failures", + Since: "2026-04-24T12:05:00Z", + AfterRunID: "20260424T120500Z-1234-001", + Threshold: 3, + SpecHash: "abc123", + LastCategory: FailureReportValidation, + }, + } + + data, err := json.Marshal(row) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got RowHealth + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.AttemptCount != 5 { + t.Fatalf("AttemptCount = %d, want 5", got.AttemptCount) + } + if got.Quarantine == nil || got.Quarantine.SpecHash != "abc123" { + t.Fatalf("Quarantine.SpecHash mismatch: %+v", got.Quarantine) + } + if got.LastFailure == nil || got.LastFailure.Category != FailureReportValidation { + t.Fatalf("LastFailure.Category mismatch: %+v", got.LastFailure) + } +} + +func TestRowHealth_OmitemptyKeepsZeroFieldsOut(t *testing.T) { + row := &RowHealth{} + data, err := json.Marshal(row) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(data) != "{}" { + t.Fatalf("zero-value RowHealth should marshal to {}, got %s", data) + } +} + +func TestItem_HealthOmitemptyByDefault(t *testing.T) { + item := &Item{Name: "x", Status: StatusPlanned} + data, err := json.Marshal(item) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(data), "health") { + t.Fatalf("Item with no health should not emit health key, got %s", data) + } +} diff --git a/internal/progress/progress.go b/internal/progress/progress.go index 9eb53d5d3..d24397453 100644 --- a/internal/progress/progress.go +++ b/internal/progress/progress.go @@ -91,6 +91,10 @@ type Item struct { Owner string `json:"owner,omitempty"` ETA string `json:"eta,omitempty"` Note string `json:"note,omitempty"` + // Health is execution-history metadata owned by autoloop. The planner + // must preserve this block verbatim across regenerations (see + // docs/superpowers/specs/2026-04-24-reactive-autoloop-design.md). + Health *RowHealth `json:"health,omitempty"` } type Subphase struct {