Skip to content

Commit 9e34209

Browse files
AbirAbbasclaude
andauthored
Two computers: continue a moved chat, clean leader quit, removed-device wording (#1967)
* Keep a chat continued at startup owned by the window that took it A bare start that continued another computer's released chat dropped the command batch Home returned, which carried the directory ask whose single-flight guard it had already set. The guard then stayed set, so the window kept the listing it read before the take: the first message opened "Continue this chat here?" for a chat this computer held, and choosing continue here retired this computer's own session and released its own hold before the conditional take was refused. Home's commands are now returned on every automatic continuation outcome. A completed take marks the chat as held here at once and replaces a listing that was asked before it. A send decides the continuation card from a fresh ownership read rather than the cached listing, and that read no longer ends an outstanding listing request. Continuing checks the fresh holder before retiring anything: a chat this computer already holds stays open as it is, and a different holder is refused before local work is touched. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Let a coordinating computer's clean quit release its chats, then hand coordination back A personal computer that led coordination ran its member inside the open terminal and stopped it when the terminal exited. The engine's closing release for the chat it held then reached a coordinator that was already leaving, got 503, and was never tried again, so the chat read as held offline until its lease ran out and coordination paused until the anchors elected a new leader. A coordinating terminal now waits, as it leaves its chats, for each idle chat's closing release to finish, then transfers leadership to an anchor and waits for an anchor to answer a fresh majority read before its member stops. That hand-back is bounded by LeaderQuitWithin (5 seconds); if the anchors cannot take over, the ordinary fallback applies. The anchors then restore their own voters as fallback already does, and the computer that quit does not get leadership back. A terminal that is not coordinating leaves exactly as before. A closing release that meets a temporary coordination refusal, including a bare 503, is retried within its existing close deadline; a definitive refusal is never retried. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Say a removed computer was removed when it asks for cluster status or lead The cluster control client read every answer other than 200, 404 and 409 as a coordination outage, including the 401 a removed device receives. So on a removed computer `codeaf cluster status` said the cluster could not coordinate and told the person to reconnect voting computers, while `codeaf devices` correctly said the device was removed. A 401 carrying the directory's revoked code is now the removed-device error, and `codeaf cluster status` and `codeaf cluster lead` explain it with the same sentence `codeaf devices` uses, from one shared definition. Every other refusal keeps its words. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Keep continued words, count only real local work, hide empty remote chats, and keep version checks side-effect free Four small findings from a two-computer run: - Choosing continue here on the card a send raised replaced the displaced session with a fresh one and lost the words held behind the card. The arriving chat's box now receives the same draft, pasted text and attachments, unsent until the person presses enter again. - Taking a chat back into a displaced window counted the retired session's closing save of unchanged bookkeeping as unsent turns, so the move said edits were kept and Home grew a branch row for turns the relay already had. A sealed turn is now kept as a branch only when the engine finds real local work in it, the same rule continuing already uses. - Every window registers its empty new chat with the directory, and the other computer listed it as an untitled running chat. A record with no saved turn is no longer drawn on another computer's Home; it appears once its first turn is saved. Registration and ownership are unchanged. - codeaf --version, help and manual requests ran the former-folder adoption and removed old scheduler timers before parsing anything, so a version check could move a state folder and recreate the compatibility link. Those informational requests now skip both; every real start keeps them. The footer's fit-by-priority, the engine outliving its window, and /quit closing the conversation in front are the documented behaviour and are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Add the change entry for the two-computer fixes The entry is numbered when the pull request exists. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Keep the manual's moving and task questions reaching the pages that answer them The new paragraphs about a send's ownership check and the words kept after continue here lengthened the page's first section until "continue on another computer" no longer reached the section that says where moving works, and the clean-quit heading competed for the same words. They now have a section of their own and the clean-quit heading says what it is about. The version-request note on the help page drew "old version of the file" away from the task pages; the starting page already says that version, help and manual requests change nothing. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Number the two-computer change entry. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Name the pull request in the two-computer change entry. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Keep a displaced window's reply bookkeeping out of kept local work Taking a chat back into a window another computer had displaced reported "your unsaved edits here were kept as 1 turn" and left a branch row on both Homes, in both directions. A real reply appends pace, usage and call accounting after its shared save, and retiring the displaced session seals that tail. Both the take's unsaved-work check and the old drive's closing publish counted it as new work. Local work is now decided from the journal beyond the shared boundary: accounting records are not work, while conversation turns, tool results, unreadable records and workspace changes are still kept. The same decision guards the unsaved-work check and the closing publish, so neither creates a branch for what the relay already has. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Carry the words typed in a displaced window into the chat it continues A send in a displaced window raises Home with the continuation card. The typed words then live in the conversation's stashed composer, not in the input Home shows, and retiring the displaced session can clear them before the moved chat opens. The moved chat opened with an empty box. The conversation's composer is now taken when the confirmed take starts, carried with the take's answer and restored into the arrived chat, even when its transcript or session differs from the displaced one. Nothing is sent until the person presses enter. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Publish a chat to other computers only after its first conversation turn A window's launch chat saves a bookkeeping head at start, and that head was published, so the other computer listed the never-used chat as "untitled running on <device>". A directory record always has a head, so the earlier filter on an empty head could never match. The drive keeps saving startup bookkeeping locally but notes a head for publication only once its saved journal holds a conversation message, bounded to that save, so text typed later cannot publish an earlier empty snapshot. A never-used chat therefore never reaches another computer's Home; it appears with its first turn and keeps ownership, quit and continue as before. A keyless end-to-end run covers the displaced take-back in both directions and both Homes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Say a removed computer was removed when its cluster commands are refused `codeaf cluster status` and `codeaf cluster lead` start with a control request whose handler answers an authentication refusal as a plain 403, so the client still reported the generic coordination failure on a removed computer. When a cluster request fails without a typed cause, the command now checks through the signed directory read whether this device was removed and, if so, explains it with the `codeaf devices` sentence. An unexplained 403 is never guessed to be a removal, and other failures keep their words. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Stop a removed computer's sync traffic until it is paired again After a removal, the open window kept listing chats, sending presence and heartbeats and running state and member rounds every few seconds, each answered 401. The sync HTTP gate now remembers the removed refusal, and so do directory watches and a committed local removal. A marker bound to this device, identity and relay shares that fact across windows and restarts, and blocked requests are answered locally with the same removed refusal. State and member rounds stand down and open members close. Pairing installs a new device key, which lifts the marker, and an open Home resumes with the new device. Local chats keep working throughout. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Read repositories without taking git's optional index lock Home's repository band ran `git status` without `--no-optional-locks`, so each refresh could create `.git/index.lock` and rewrite `.git/index`. When that landed during a move's in-place restore, the restore saw `.git` change and cancelled, and the take gave the chat back. Every git command codeaf runs on its own to read a repository now passes `--no-optional-locks`, so a background read never takes the index lock or rewrites the index. Commands that deliberately write are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Send only from a chat this window holds, and leave a failed move safe After a failed take the chat was free, so the window's send door, which only blocked a chat another computer held, let a turn run on a stale copy that could never publish. A send now goes ahead only when this window holds the chat at the fence it recorded, or the chat was never shared. A free chat whose local head equals the shared head is taken back quietly with a conditional take, then sent; otherwise the continuation card or the view-only state appears and the words stay in the box. A failed take marks the window's copy as one it may not send on and says why in plain words, and a stale-fence refusal makes the window view-only. When the ownership check cannot reach the other computers, a chat this window holds keeps working and its turns wait to be shared, and anything else stays unsent. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Keep the whole draft when continuing a chat fails The draft captured when a take started was restored only on success, so a lost race, a refused or unreachable take, or a failed in-place restore lost the words typed behind the card. Every failure path now puts the draft back in the box, unsent, and the manual says what a failed move leaves behind. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Offer nothing about continuing on a computer that was removed A removed computer's Home still offered "Continue <device>'s latest chat here?" with its chord. Once this computer knows it was removed, Home drops the left-off offer, the continue chord and the continue action on rows, and keeps the removal sentence. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Explain a removed computer's stopped syncing without the word relay The device pages keep to the words people use for their computers; the removal paragraph now says the computer stops asking the other computers for chats, and that pairing can still reach the service. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Pace the removal traffic test's heartbeats until the removal is known The test drove the drive's heartbeat every 10 ms from the start, which queued relay commits ahead of the member's first catch-up. On a slow disk the member round waited minutes, and the request signed before that wait was refused as clock skew. The heartbeat now keeps an ordinary pace until the removal is learned and only then runs fast, so the observation still spans many heartbeat intervals. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Announce a displaced chat only when another computer took it After every take the window that took the chat said "<its own device> continued this chat; this window now only shows it". The take's acquire is followed by the arrived drive's own acquire, which raises the fence, and the window compared the listing's fence with the hold it recorded at the take. The window's recorded hold now follows its own drive's publishing receipt, so a newer fence held by this computer for this window is still this window's hold. The notice and the card come only from a hold by another computer; a stale local receipt still cannot send. The second acquire stays: it is the boundary that keeps two local drives from publishing under one fence. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Keep each chat's draft with that chat when continuing from Home A take captured whatever the window's main box held and restored it into the chat that arrived, so an unsent draft typed for another chat appeared in the moved chat's box and was saved as its draft. The arrived chat's box now holds only the words typed for it: the draft held behind the card its own send raised, otherwise its own saved draft, otherwise nothing. A draft typed for another chat stays with that chat, including when a take fails. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Keep the directory's orphaned draft adoption, and limit draft ownership to takes Keeping each chat's draft with that chat had also stopped an ordinary opening from adopting the draft a dead window left in its directory, which is how the words follow the person and the directory. Adoption is restored. Only a chat arriving through a take now brings just its own saved words and never adopts another chat's draft. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Keep "how do I add another computer" reaching the pairing steps The removal and empty-chat sections spoke of "another computer" and "adding the computer again", which drew the question away from the section with the pairing steps once that section's card text changed. They now say "machine" and "your other devices", and the question reaches the steps again. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 08b922b commit 9e34209

106 files changed

Lines changed: 4058 additions & 188 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎cmd/codeaf-demo-home/seed.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ func initRepository(dir string, now time.Time) error {
285285
{"add", "."},
286286
{"-c", "user.name=Demo", "-c", "user.email=demo@example.invalid", "commit", "--quiet", "-m", "the first commit"},
287287
} {
288-
command := exec.Command("git", append([]string{"-C", dir}, argv...)...)
288+
command := exec.Command("git", append([]string{"--no-optional-locks", "-C", dir}, argv...)...)
289289
command.Env = append(os.Environ(),
290290
"GIT_AUTHOR_DATE="+stamp, "GIT_COMMITTER_DATE="+stamp,
291291
// A demo home must not inherit the machine's git configuration:

‎cmd/codeaf-release/promotion.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ func runPromotionDecision(args []string, out io.Writer) error {
8686

8787
// gitAt runs read-only git queries in the selected repository fixture or checkout.
8888
func gitAt(repo string, args ...string) (string, error) {
89-
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
89+
cmd := exec.Command("git", append([]string{"--no-optional-locks", "-C", repo}, args...)...)
9090
out, err := cmd.Output()
9191
if err != nil {
9292
return "", fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
@@ -96,7 +96,7 @@ func gitAt(repo string, args ...string) (string, error) {
9696

9797
// ancestor uses git's ancestry relation because a timestamp cannot prove a safe fast-forward.
9898
func ancestor(repo, older, newer string) bool {
99-
cmd := exec.Command("git", "-C", repo, "merge-base", "--is-ancestor", older, newer)
99+
cmd := exec.Command("git", "--no-optional-locks", "-C", repo, "merge-base", "--is-ancestor", older, newer)
100100
return cmd.Run() == nil
101101
}
102102

‎cmd/codeaf/chatv3_layout.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ func v3GitRoot(dir string) (string, bool) {
182182
}
183183
ctx, cancel := context.WithTimeout(context.Background(), v3GitRootTimeout)
184184
defer cancel()
185-
command := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
185+
command := exec.CommandContext(ctx, "git", "--no-optional-locks", "rev-parse", "--show-toplevel")
186186
command.Dir = dir
187187
out, err := command.Output()
188188
if err != nil {

‎cmd/codeaf/chatv3_place.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ func initOwnedRepository(work string) error {
120120
// an editor would hang a launch on a terminal it is about to hand to the
121121
// surface.
122122
func gitIn(dir string, args ...string) (string, error) {
123-
command := exec.Command("git", args...)
123+
command := exec.Command("git", append([]string{"--no-optional-locks"}, args...)...)
124124
command.Dir = dir
125125
command.Env = append(os.Environ(), "GIT_PAGER=cat", "GIT_EDITOR=true", "GIT_TERMINAL_PROMPT=0")
126126
out, err := command.CombinedOutput()

‎cmd/codeaf/chatv3_quit_test.go‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"crypto/sha256"
6+
"encoding/hex"
7+
"net/http/httptest"
8+
"os"
9+
"path/filepath"
10+
"testing"
11+
"time"
12+
13+
"github.com/Agent-Field/codeaf/internal/cell/celltest"
14+
"github.com/Agent-Field/codeaf/internal/filelock"
15+
"github.com/Agent-Field/codeaf/internal/home"
16+
"github.com/Agent-Field/codeaf/internal/identity"
17+
"github.com/Agent-Field/codeaf/internal/relayserve"
18+
"github.com/Agent-Field/codeaf/internal/syncsetup"
19+
"github.com/Agent-Field/codeaf/internal/tui3"
20+
)
21+
22+
// A clean quit requires the shared terminal exit to leave an anchor answering fresh
23+
// reads before the terminal's personal member has stopped.
24+
func TestClusterSurfaceQuitHandsBackBeforeStoppingMember(t *testing.T) {
25+
celltest.Available(t)
26+
root, err := os.MkdirTemp("", "l2lfix-surface-")
27+
if err != nil {
28+
t.Fatal(err)
29+
}
30+
t.Cleanup(func() { _ = os.RemoveAll(root) })
31+
t.Setenv("CODEAF_HOME", root)
32+
_, err = identity.Ensure(home.Dir())
33+
if err != nil {
34+
t.Fatal(err)
35+
}
36+
svc := relayserve.New(relayserve.Config{Store: t.TempDir()})
37+
defer svc.Close()
38+
server := httptest.NewServer(svc.Handler)
39+
defer server.Close()
40+
t.Setenv(syncsetup.URLVar, server.URL)
41+
previous := runSurfaceProgram
42+
t.Cleanup(func() { runSurfaceProgram = previous })
43+
var leader uint64
44+
runSurfaceProgram = func(ctx context.Context, options tui3.Options) error {
45+
s, err := syncOf()
46+
if err != nil {
47+
return err
48+
}
49+
deadline := time.Now().Add(20 * time.Second)
50+
for time.Now().Before(deadline) {
51+
sum := sha256.Sum256([]byte(s.Ledger + "/" + s.Device.ID()))
52+
lock, err := os.Open(filepath.Join(s.Home, "cluster-member", hex.EncodeToString(sum[:]), "lock"))
53+
owned := false
54+
if err == nil {
55+
owned = filelock.IsBusy(filelock.Lock(lock, true, true))
56+
_ = lock.Close()
57+
}
58+
if owned {
59+
receipt, err := s.LeadCluster(ctx, 0)
60+
if err == nil {
61+
leader = receipt.Node
62+
if options.WaitForRelease == nil || !options.WaitForRelease() {
63+
t.Fatal("coordinating quit did not join closing releases")
64+
}
65+
return nil
66+
}
67+
}
68+
time.Sleep(10 * time.Millisecond)
69+
}
70+
t.Fatal("personal member did not lead while the surface was open")
71+
return nil
72+
}
73+
if err := runSurface(context.Background(), tui3.Options{ProfileDir: root}); err != nil {
74+
t.Fatal(err)
75+
}
76+
s, err := syncOf()
77+
if err != nil {
78+
t.Fatal(err)
79+
}
80+
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
81+
defer cancel()
82+
status, err := s.ClusterStatus(ctx)
83+
if err != nil || status.Leader == leader || status.Leader == 0 || status.Leader > 3 {
84+
t.Fatalf("quit returned before an anchor could coordinate: %+v %v", status, err)
85+
}
86+
}

‎cmd/codeaf/chatv3_surface.go‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ func runSurface(ctx context.Context, options tui3.Options) error {
6262
options.CellsUnavailable = cell.UnavailableReason()
6363
if options.CellsUnavailable == "" {
6464
wireSync(&options)
65+
options.WaitForRelease = func() bool {
66+
s, err := syncOf()
67+
return err == nil && s.Coordinating()
68+
}
6569
stateCtx, stopState := context.WithCancel(ctx)
6670
stateDone := make(chan struct{})
6771
guard.Go("sync/home-state", func() {
@@ -72,7 +76,14 @@ func runSurface(ctx context.Context, options tui3.Options) error {
7276
})
7377
copiesDone := make(chan struct{})
7478
guard.Go("sync/older-chats", func() { defer close(copiesDone); runOlderChats(stateCtx) })
75-
defer func() { stopState(); <-stateDone; <-copiesDone }()
79+
defer func() {
80+
if s, err := syncOf(); err == nil {
81+
_ = s.HandBack(ctx)
82+
}
83+
stopState()
84+
<-stateDone
85+
<-copiesDone
86+
}()
7687
options.Pairing = chatPairDoor{}
7788
options.Approvals = newApprovalsDoor(home.Dir())
7889
}

‎cmd/codeaf/cluster.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,14 @@ func clusterCommand(ctx context.Context, c clusterDoor, verb string, out io.Writ
5151
if verb == "lead" {
5252
r, err := c.LeadCluster(ctx, 0)
5353
if err != nil {
54-
return err
54+
return explainRemovedDevice(err)
5555
}
5656
_, err = fmt.Fprintf(out, "computer %d is coordinating · term %d · revision %d · read barrier %d\n", r.Node, r.Term, r.Revision, r.Barrier)
5757
return err
5858
}
5959
s, err := c.ClusterStatus(ctx)
6060
if err != nil {
61-
return err
61+
return explainRemovedDevice(err)
6262
}
6363
if s.Leader != 0 {
6464
fmt.Fprintf(out, "coordinator %d\n", s.Leader)

‎cmd/codeaf/cluster_removed_test.go‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"errors"
7+
"testing"
8+
9+
"github.com/Agent-Field/codeaf/internal/directory"
10+
)
11+
12+
func TestClusterRemovedDeviceUsesDevicesSentence(t *testing.T) {
13+
want := (chatsKind{}).explain(directory.ErrRevoked).Error()
14+
for _, verb := range []string{"status", "lead"} {
15+
t.Run(verb, func(t *testing.T) {
16+
var out bytes.Buffer
17+
err := clusterCommand(context.Background(), clusterFixture{err: directory.ErrRevoked}, verb, &out)
18+
if err == nil || exitCodeOf(err) == 0 || !errors.Is(err, directory.ErrRevoked) {
19+
t.Fatalf("removed command must fail with its cause: %v", err)
20+
}
21+
if got := plainWords(err.Error()); got != plainWords(want) || out.Len() != 0 {
22+
t.Fatalf("command says %q, want %q; stdout=%q", got, plainWords(want), out.String())
23+
}
24+
})
25+
}
26+
}

‎cmd/codeaf/devices.go‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,7 @@ func (c chatsKind) revoke(row chatsRow) (string, error) {
407407
func (c chatsKind) explain(err error) error {
408408
switch {
409409
case errors.Is(err, directory.ErrRevoked):
410-
return toldLine{line: "this device was removed by another of your devices, so it can no longer reach your chats — run `codeaf pair` to bring it back", err: err}
410+
return explainRemovedDevice(err)
411411
case errors.Is(err, wireauth.ErrSkew):
412412
return errors.New(chatlist.ClockOff)
413413
case errors.Is(err, directory.ErrUnreachable):
@@ -417,3 +417,11 @@ func (c chatsKind) explain(err error) error {
417417
}
418418
return err
419419
}
420+
421+
// explainRemovedDevice gives every command the same removal and recovery words.
422+
func explainRemovedDevice(err error) error {
423+
if errors.Is(err, directory.ErrRevoked) {
424+
return toldLine{line: "this device was removed by another of your devices, so it can no longer reach your chats — run `codeaf pair` to bring it back", err: err}
425+
}
426+
return err
427+
}

‎cmd/codeaf/main.go‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,12 @@ import (
4545
)
4646

4747
func main() {
48-
home.Adopt(log.Printf)
49-
// CODEAF SCHEDULES NOTHING ON THE MACHINE. The timers the feature that
50-
// automations replaced installed are taken off this login on every start,
51-
// whoever installed them (internal/automation's legacy.go).
52-
automation.RemoveOldTimers()
48+
if startupMaintenanceNeeded(os.Args[1:]) {
49+
home.Adopt(log.Printf)
50+
// CODEAF SCHEDULES NOTHING ON THE MACHINE. Real starts remove the
51+
// timers installed by the feature that automations replaced.
52+
automation.RemoveOldTimers()
53+
}
5354
registerRunningCLI()
5455
os.Exit(execute())
5556
}

0 commit comments

Comments
 (0)