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
188 changes: 14 additions & 174 deletions cmd/codeaf-release/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,129 +4,13 @@ import (
"bufio"
"fmt"
"io"
"regexp"
"strconv"
"strings"
)

var (
stableTagPattern = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`)
rcTagPattern = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-rc\.([1-9][0-9]*)$`)
devTagPattern = regexp.MustCompile(`^dev-[0-9]{8}-[0-9a-f]{12}$`)
stagingTagPattern = regexp.MustCompile(`^staging-[0-9]{8}-[0-9a-f]{12}$`)
shaPattern = regexp.MustCompile(`^[0-9a-fA-F]{12,40}$`)
datePattern = regexp.MustCompile(`^[0-9]{8}$`)
codeupdate "github.com/Agent-Field/codeaf/internal/update"
)

type version struct {
major int
minor int
patch int
}

func (v version) String() string {
return fmt.Sprintf("v%d.%d.%d", v.major, v.minor, v.patch)
}

func (v version) compare(other version) int {
switch {
case v.major != other.major:
return compareInt(v.major, other.major)
case v.minor != other.minor:
return compareInt(v.minor, other.minor)
default:
return compareInt(v.patch, other.patch)
}
}

func compareInt(left, right int) int {
switch {
case left < right:
return -1
case left > right:
return 1
default:
return 0
}
}

func (v version) bump(component string) (version, error) {
switch component {
case "patch":
v.patch++
case "minor":
v.minor++
v.patch = 0
case "major":
v.major++
v.minor = 0
v.patch = 0
default:
return version{}, usageErr("component %q must be patch, minor, or major", component)
}
return v, nil
}

type rcTag struct {
base version
counter int
}

func parseComponent(raw string) (int, bool) {
value, err := strconv.Atoi(raw)
// Every accepted component must leave room for the resolver's next bump.
// A number that this process cannot represent or increment is a junk tag,
// just like a tag that does not match the grammar at all.
if err != nil || value == int(^uint(0)>>1) {
return 0, false
}
return value, true
}

func parseVersion(match []string) (version, bool) {
major, majorOK := parseComponent(match[1])
minor, minorOK := parseComponent(match[2])
patch, patchOK := parseComponent(match[3])
if !majorOK || !minorOK || !patchOK {
return version{}, false
}
return version{major: major, minor: minor, patch: patch}, true
}

func parseStable(tag string) (version, bool) {
match := stableTagPattern.FindStringSubmatch(tag)
if match == nil {
return version{}, false
}
return parseVersion(match)
}

func parseRC(tag string) (rcTag, bool) {
match := rcTagPattern.FindStringSubmatch(tag)
if match == nil {
return rcTag{}, false
}
base, baseOK := parseVersion(match)
counter, counterOK := parseComponent(match[4])
if !baseOK || !counterOK {
return rcTag{}, false
}
return rcTag{base: base, counter: counter}, true
}

func tagKind(tag string) string {
switch {
case stableTagPattern.MatchString(tag):
return "stable"
case rcTagPattern.MatchString(tag):
return "rc"
case devTagPattern.MatchString(tag):
return "dev"
case stagingTagPattern.MatchString(tag):
return "staging"
default:
return "other"
}
return codeupdate.Kind(tag)
}

type nextOptions struct {
Expand All @@ -142,7 +26,10 @@ func runNext(args []string, stdin io.Reader, stdout io.Writer) error {
return err
}
if options.channel == "dev" || options.channel == "staging" {
tag := fmt.Sprintf("%s-%s-%s", options.channel, options.date, strings.ToLower(options.sha[:12]))
tag, err := codeupdate.ChannelTag(options.channel, options.date, options.sha)
if err != nil {
return err
}
fmt.Fprintln(stdout, tag)
return nil
}
Expand Down Expand Up @@ -186,14 +73,14 @@ func parseNextOptions(args []string) (nextOptions, error) {
if options.sha != "" || options.date != "" {
return options, usageErr("--sha and --date are only for dev and staging")
}
if _, err := (version{}).bump(options.component); err != nil {
return options, err
if _, err := (codeupdate.Version{}).Bump(options.component); err != nil {
return options, usageErr("%s", err)
}
case "dev", "staging":
if !shaPattern.MatchString(options.sha) {
if !codeupdate.ValidSHA(options.sha) {
return options, usageErr("--sha must be 12 to 40 hexadecimal characters")
}
if !datePattern.MatchString(options.date) {
if !codeupdate.ValidDate(options.date) {
return options, usageErr("--date must be YYYYMMDD")
}
if options.component != "patch" {
Expand All @@ -218,58 +105,11 @@ func readTags(reader io.Reader) ([]string, error) {
}

func nextSemverTag(tags []string, channel, component string) (string, error) {
stableLatest := version{}
for _, tag := range tags {
if candidate, ok := parseStable(tag); ok && candidate.compare(stableLatest) > 0 {
stableLatest = candidate
}
}

var (
openLine version
haveOpen bool
maxCounter int
)
for _, tag := range tags {
candidate, ok := parseRC(tag)
if !ok || candidate.base.compare(stableLatest) <= 0 {
continue
}
switch comparison := candidate.base.compare(openLine); {
case !haveOpen || comparison > 0:
openLine, haveOpen, maxCounter = candidate.base, true, candidate.counter
case comparison == 0 && candidate.counter > maxCounter:
maxCounter = candidate.counter
}
answer, err := codeupdate.NextSemverTag(tags, channel, component)
if err != nil && (strings.Contains(err.Error(), "component") || strings.Contains(err.Error(), "channel")) {
return "", usageErr("--%s", err)
}

var answer string
switch channel {
case "rc":
if haveOpen {
answer = fmt.Sprintf("%s-rc.%d", openLine, maxCounter+1)
} else {
next, err := stableLatest.bump(component)
if err != nil {
return "", err
}
answer = next.String() + "-rc.1"
}
case "stable":
if component == "patch" && haveOpen {
answer = openLine.String()
} else {
next, err := stableLatest.bump(component)
if err != nil {
return "", err
}
answer = next.String()
}
default:
return "", usageErr("--channel must be stable, rc, dev, or staging")
}

return answer, requireUnused(answer, tags)
return answer, err
}

// requireUnused is a final collision guard kept separate from version choice.
Expand Down
16 changes: 16 additions & 0 deletions cmd/codeaf-release/version_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,19 @@ func TestUsageErrorsUseExitTwoAndKeepStdoutEmpty(t *testing.T) {
}
}
}

// TestC12ReleaseCommandsKeepTheirPublicBehaviour proves C12.
func TestC12ReleaseCommandsKeepTheirPublicBehaviour(t *testing.T) {
code, stdout, stderr := invoke([]string{"next", "--channel", "stable"}, "v0.1.0\n")
if code != 0 || stdout != "v0.1.1\n" || stderr != "" {
t.Fatalf("next: code %d stdout %q stderr %q", code, stdout, stderr)
}
code, stdout, stderr = invoke([]string{"kind", "v0.1.1-rc.1"}, "")
if code != 0 || stdout != "rc\n" || stderr != "" {
t.Fatalf("kind: code %d stdout %q stderr %q", code, stdout, stderr)
}
code, stdout, stderr = invoke([]string{"prune", "--channel", "dev"}, "v0.1.0\t2026-09-15T00:00:00Z\n")
if code != 0 || stdout != "" || stderr != "" {
t.Fatalf("prune: code %d stdout %q stderr %q", code, stdout, stderr)
}
}
59 changes: 55 additions & 4 deletions cmd/codeaf/chatv3.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/connect"
"github.com/Agent-Field/codeaf/internal/effort"
"github.com/Agent-Field/codeaf/internal/enginehost"
"github.com/Agent-Field/codeaf/internal/env"
"github.com/Agent-Field/codeaf/internal/guard"
"github.com/Agent-Field/codeaf/internal/home"
Expand All @@ -30,6 +31,7 @@ import (
"github.com/Agent-Field/codeaf/internal/subharness"
"github.com/Agent-Field/codeaf/internal/trace"
"github.com/Agent-Field/codeaf/internal/tui3"
codeupdate "github.com/Agent-Field/codeaf/internal/update"
)

// runChatV3 is the v3 door: one session agent over this directory, and the
Expand Down Expand Up @@ -68,6 +70,7 @@ func v3OpenRouterConnection(settings config.Config, interactive bool) func(conte
}

func openChatV3(name string, args []string, pickSession bool) error {
restart := &codeupdate.Plan{}
flags := commandFlags(name)
model := flags.String("model", "", "model slug for this session; beats the configured default")
once := flags.String("once", "", "run one message non-interactively, print the reply, and exit")
Expand Down Expand Up @@ -184,7 +187,7 @@ func openChatV3(name string, args []string, pickSession bool) error {
// the harnesses are all the far machine's, and reading this one's would be
// resolving a launch nobody asked for (chatv3_host.go).
if dest := strings.TrimSpace(*host); dest != "" {
return openChatV3Host(hostLaunch{
err := openChatV3Host(hostLaunch{
target: dest,
session: strings.TrimSpace(*file),
model: strings.TrimSpace(*model),
Expand All @@ -194,7 +197,9 @@ func openChatV3(name string, args []string, pickSession bool) error {
noCompact: *noCompact,
yolo: *yolo,
budget: chatBudget(*maxHours, *maxCost).Set(),
restart: restart,
})
return finishChatRestart(err, restart, "")
}

// THE THIRD DOOR, and it forks here for the reason --host does: a machine
Expand All @@ -203,7 +208,7 @@ func openChatV3(name string, args []string, pickSession bool) error {
// a relay tunnel here — which is the whole point of the transport being an
// io.ReadWriteCloser and nothing more (chatv3_at.go, docs/REMOTE.md).
if name := strings.TrimSpace(*at); name != "" {
return openChatV3At(atLaunch{
err := openChatV3At(atLaunch{
target: name,
session: strings.TrimSpace(*file),
model: strings.TrimSpace(*model),
Expand All @@ -213,7 +218,9 @@ func openChatV3(name string, args []string, pickSession bool) error {
noCompact: *noCompact,
yolo: *yolo,
budget: chatBudget(*maxHours, *maxCost).Set(),
restart: restart,
})
return finishChatRestart(err, restart, "")
}

// The fourth door, and the only one with no machine in it: this workspace's
Expand Down Expand Up @@ -255,6 +262,7 @@ func openChatV3(name string, args []string, pickSession bool) error {
once: strings.TrimSpace(*once),
pick: pickSession,
shape: v3LaunchShape(*yolo, *noCompact, *oneModel, *maxHours, *maxCost, strings.TrimSpace(*once) == ""),
restart: restart,
})
var taken *hostShapeTaken
var unreachable *hostUnreachable
Expand All @@ -264,7 +272,7 @@ func openChatV3(name string, args []string, pickSession bool) error {
case errors.As(err, &unreachable):
entryNotice = "this conversation opened in this terminal instead, and ends with it: " + unreachable.reason
default:
return err
return finishChatRestart(err, restart, workspace)
}
}

Expand Down Expand Up @@ -461,7 +469,7 @@ func openChatV3(name string, args []string, pickSession bool) error {
// The byte meter and the logger redirect both belong to the surface rather
// than to this door, and [runSurface] (chatv3_surface.go) is where every
// door gets them.
return runSurface(ctx, tui3.Options{
err = runSurface(ctx, tui3.Options{
Agent: agent,
Build: buildinfo.String(),
// The memory place and the search place read the SAME database the
Expand Down Expand Up @@ -613,6 +621,7 @@ func openChatV3(name string, args []string, pickSession bool) error {
Owned: launch.Place.Owned,
SessionFile: transcript,
Resumed: resumed,
Restart: restart,
// AND THE LAUNCH FACTS A PERSON CAN ACT ON. The unattended boundary and
// a replaced codeaf both ride the session-moved line — one dim row at
// the top of the conversation — rather than growing surfaces of their
Expand All @@ -637,6 +646,48 @@ func openChatV3(name string, args []string, pickSession bool) error {
SaveBashApproval: bankBashApproval(agent, workspace, settings.ProfileDir, *yolo),
ApplyApprovals: applyV3Approvals(agent, workspace, settings.ProfileDir, *yolo),
})
return finishChatRestart(err, restart, "")
}

// finishChatRestart runs only after the surface door has returned, so every
// connection, session, and terminal cleanup deferred by that door has finished.
func finishChatRestart(err error, restart *codeupdate.Plan, localWorkspace string) error {
if err != nil || restart == nil || restart.Path == "" {
return err
}
// A linked-local engine outlives its surface, so replacing the executable
// does not by itself put the new build behind the conversation. Ask it to
// retire only after the surface has detached; the host itself refuses while
// another view or any work is active, and a refusal never stops that work.
if localWorkspace != "" {
retireUpdateEngine(localWorkspace)
}
return restartUpdatedChat(*restart)
}

const updateEngineDetachGrace = time.Second
const updateEngineDetachPoll = 25 * time.Millisecond

var (
retireChatEngine = enginehost.Retire
restartUpdatedChat = codeupdate.Restart
updateRestartNow = time.Now
updateRestartPause = time.Sleep
)

// retireUpdateEngine gives the connection close immediately above long enough
// to reach the host before deciding that somebody else really is keeping it.
// It never uses the force door: another window or live work keeps the old host,
// while the ordinary one-window handoff reliably starts the replacement build.
func retireUpdateEngine(workspace string) {
deadline := updateRestartNow().Add(updateEngineDetachGrace)
for {
err := retireChatEngine(workspace, false)
if !errors.Is(err, enginehost.ErrHostBusy) || !updateRestartNow().Before(deadline) {
return
}
updateRestartPause(updateEngineDetachPoll)
}
}

// ── the shared assembly ─────────────────────────────────────────────────────
Expand Down
Loading
Loading