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
31 changes: 31 additions & 0 deletions .github/workflows/go-prototype.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Go prototype

on:
pull_request:
paths: ['prototype/go/**', '.github/workflows/go-prototype.yml']
push:
branches: [main]
paths: ['prototype/go/**', '.github/workflows/go-prototype.yml']

concurrency:
group: go-prototype-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
go-prototype:
runs-on: ubuntu-24.04
timeout-minutes: 10
defaults:
run:
working-directory: prototype/go
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: prototype/go/go.mod
cache: false
- run: python3 bundle-skills.py --check
- run: test -z "$(gofmt -l *.go)"
- run: go vet ./...
- run: go test ./...
- run: CGO_ENABLED=0 go build -o "$RUNNER_TEMP/thirdshift-go" .
8 changes: 6 additions & 2 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,13 @@ PASS cargo nextest run (42.15s; log: /checkout/.affected-tests/cargo-nextest-run

| Changed paths | Checks |
| --- | --- |
| `prototype/go/**` only | `go test ./...` from `prototype/go/`; no Rust suite |
| `site/**`, `docs/guide.md` | `npm test --prefix site`; `cargo test --test share_card --test site_deploy`; `cargo test --bin thirdshift prompts_page` |
| `console/**`: the Console browser tests, their fixtures and packages | Console browser tests: `cargo build --locked`, then `npm test --prefix console` |
| `src/console.rs`, `src/console/**` (including templates and served assets), `src/config/console.rs`, `src/config/console/**`, and the shared fixtures the Console browser tests read: `tests/console/scoring_fixture.py`, `tests/fixtures/waterfall-{logs,spans}.jsonl` | Console browser tests and the full Rust suite |
| `scripts/release*`, `scripts/generate-release.py`, `scripts/test_generate_release.py` | `cargo test --test release`; `python3 -m unittest discover -s scripts -p test_generate_release.py` |
| Everything else, including `src/**`, shared test support/fakes, Cargo files and CI configuration | Full Rust suite |
| Several areas together | Full Rust suite plus any selected site, Console and release-generator checks; the full suite subsumes the targeted Rust checks |
| Several areas together | Full Rust suite plus any selected site, Console and release-generator checks; the full suite subsumes the targeted Rust checks. Go is independent: add its suite only when `prototype/go/**` changes, without broadening the other selections. |

Unknown paths print the reason for selecting the full Rust suite, and Console
paths print the reason for selecting the Console browser tests. The mappings
Expand Down Expand Up @@ -78,7 +79,10 @@ and the Linux Rust suite. CI uses only Linux runners: x86_64 on every PR and
aarch64 on `main` pushes, nightly and manual runs, plus PRs into `main` labelled
`test:linux-arm`. See [CI performance](ci-performance.md). This script selects
Rust, site, Console browser and release checks; it does not replace CI's
coverage of both Linux architectures. Run local checks in Linux, including
coverage of both Linux architectures. The isolated Go prototype has its own
path-filtered CI workflow; ordinary Rust changes require no Go toolchain.
See [its README](../prototype/go/README.md) for setup and static builds.
Run local checks in Linux, including
an ARM Linux VM when contributing from an Apple Silicon Mac.

Measure local full-suite time with `/usr/bin/time -p cargo nextest run` (or
Expand Down
73 changes: 73 additions & 0 deletions prototype/go/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Go Ticket-run prototype

An isolated, Linux-only experiment for #1830 / #1832. The production Rust
binary and ADR 0001 stay as they are. This slice stops after the opening
Harness session, before Delivery, CI watch, Repair or Pickup.

Requires Go (the version in `go.mod`), Python 3 for tests/generation, Git,
authenticated `gh`, and Claude Code (`claude`) for real sessions. Tests use
fake `gh` and `claude` executables and never contact GitHub or an LLM.

From this directory:

```sh
go test ./...
CGO_ENABLED=0 go build -o /tmp/thirdshift-go .
```

The build produces a single static Linux binary; the CLI tests inspect its
ELF program headers and imported libraries. No Go dependencies are needed.
Run it from the Launch directory of a disposable repository:

```sh
/tmp/thirdshift-go https://github.com/owner/repo/issues/7
```

It checks Origin match, an open issue, and the issue author's Trusted account
association (`OWNER`, `MEMBER`, `COLLABORATOR`) before making a Claim. The
Launch directory must have a checked-out Base branch. This first slice creates
`issue-7` and a sibling Worktree `<launch-name>-issue-7`; an existing branch
or directory fails safely rather than resuming or overwriting it.

The Claim replaces `ready-for-agent` and clears `agent-failed`, preserving
other labels. The session gets `.thirdshift-issue.md` with the title, body,
and paginated comments, replacing each untrusted comment with a notice.
Factory skills are linked under `.claude/skills/thirdshift-*`. Both those
links and the issue file are excluded through the shared Git `info/exclude`.
The Worktree and materialized skills in the shared Git directory are retained
for inspection, including on failure. Remove the Worktree with `git worktree
remove` and its unused `thirdshift-go-skills-*` directory when finished.

Claude runs with null stdin, `-p --permission-mode auto --output-format
stream-json --verbose`. Parsed JSON events are emitted immediately to stdout;
malformed events, missing completion, error results, and process failures fail
the Ticket run. `SIGINT` or `SIGTERM` interrupts its current child process
group: SIGINT, a two-second grace, SIGTERM, another two seconds, then SIGKILL.
If the leader exits early, remaining group members are killed immediately.
Interruption exits 130. Claim ending uses its own bounded context so it still
finishes after interruption: a closed issue loses its Claim; an open issue
with no Issue branch or open PR on origin has only this run's label changes
restored (`ready-for-agent` only if it was present before acquisition, and
`in-progress` removed only if this run added it). Work left on origin keeps
its Claim, with `agent-failed` on failed or interrupted runs.

## Embedded Factory skills

Go cannot embed files outside its module. `factory-skills.json` is a generated
snapshot of the root `skills/` tree, embedded using `embed.FS`. Refresh it
explicitly when evaluating newer Factory skills:

```sh
go generate
python3 bundle-skills.py --check
```

The snapshot includes supporting files and licenses. Root Factory-skill edits
do not require the Go toolchain or run its suite. The prototype CI workflow
runs only when the prototype or its workflow changes, and checks snapshot
freshness. Existing Rust CI keeps its coverage.

From the repository root, `scripts/affected-tests issue-1830` selects Go tests
only when a path under `prototype/go/` changed. A Go-only change runs no Rust
tests. Mixed changes keep each area's checks; adding Go to a site-only change
does not expand its targeted Rust checks into the full Rust suite.
20 changes: 20 additions & 0 deletions prototype/go/bundle-skills.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env python3
"""Snapshot Factory skills inside the Go module (go:embed cannot reach ../..)."""
import argparse
import json
from pathlib import Path

parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
module = Path(__file__).resolve().parent
source = module.parent.parent / "skills"
files = {path.relative_to(source).as_posix(): path.read_text()
for path in sorted(source.rglob("*")) if path.is_file()}
snapshot = json.dumps(files, ensure_ascii=False, indent=2) + "\n"
target = module / "factory-skills.json"
if args.check:
if target.read_text() != snapshot:
raise SystemExit("Factory skills snapshot is stale; run go generate")
else:
target.write_text(snapshot)
73 changes: 73 additions & 0 deletions prototype/go/claim.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package main

import (
"context"
"fmt"
)

func makeClaim(ctx context.Context, issue issueURL, data issueData) error {
if _, err := command(ctx, "", "gh", "label", "create", "in-progress", "--repo", issue.repo,
"--description", "A Claim: thirdshift has taken this issue", "--force"); err != nil {
return err
}
args := []string{"issue", "edit", issue.number, "--repo", issue.repo, "--add-label", "in-progress"}
for _, label := range []string{"ready-for-agent", "agent-failed"} {
if data.hasLabel(label) {
args = append(args, "--remove-label", label)
}
}
_, err := command(ctx, "", "gh", args...)
return err
}

// This slice ends before Delivery: release only when nothing remains on origin.
// Never reuse the cancelled Ticket-run context here.
func endClaim(ctx context.Context, launch string, issue issueURL, branch string, initial issueData, failed bool) error {
data, err := readIssue(ctx, issue)
if err != nil {
return err
}
if !data.hasLabel("in-progress") {
return nil
}
args := []string{"issue", "edit", issue.number, "--repo", issue.repo}
unchanged := len(args)
if data.State == "open" {
refs, err := command(ctx, launch, "git", "ls-remote", "--heads", "origin", "refs/heads/"+branch)
if err != nil {
return fmt.Errorf("ending Claim: %w", err)
}
var prs []struct {
Number int `json:"number"`
}
if err := ghJSON(ctx, &prs, "pr", "list", "--repo", issue.repo, "--head", branch, "--state", "open", "--json", "number"); err != nil {
return err
}
if len(refs) != 0 || len(prs) != 0 {
if !failed {
return nil
}
if _, err := command(ctx, "", "gh", "label", "create", "agent-failed", "--repo", issue.repo, "--description", "Failed Ticket run", "--force"); err != nil {
return err
}
args = append(args, "--add-label", "agent-failed")
} else {
if !initial.hasLabel("in-progress") {
args = append(args, "--remove-label", "in-progress")
}
if initial.hasLabel("ready-for-agent") {
args = append(args, "--add-label", "ready-for-agent")
}
}
} else {
args = append(args, "--remove-label", "in-progress")
if data.hasLabel("agent-failed") {
args = append(args, "--remove-label", "agent-failed")
}
}
if len(args) == unchanged {
return nil
}
_, err = command(ctx, "", "gh", args...)
return err
}
Loading