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
113 changes: 113 additions & 0 deletions .github/workflows/deploy-gormes-www.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions docs/content/building-gormes/architecture_plan/progress.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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",
Expand Down
54 changes: 54 additions & 0 deletions internal/gateway/fake_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 9 additions & 3 deletions internal/gateway/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
64 changes: 64 additions & 0 deletions internal/gateway/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
45 changes: 45 additions & 0 deletions internal/progress/health.go
Original file line number Diff line number Diff line change
@@ -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"`
}
Loading