mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 05:19:33 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd7c2736dc | ||
|
|
cfd065b565 | ||
|
|
fb3a0bff44 |
No files matched your search
@@ -82,6 +82,18 @@ If you are testing before the first public release, build from source:
|
||||
go build -o zero ./cmd/zero
|
||||
```
|
||||
|
||||
On Linux, build the sandbox helper too if you want native sandboxing:
|
||||
|
||||
```bash
|
||||
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
|
||||
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
|
||||
```
|
||||
|
||||
Put `zero` and `zero-linux-sandbox` in the same directory on `PATH`
|
||||
(`~/.local/bin` is a good default). macOS does not need an extra helper binary.
|
||||
Windows source builds can use the main `zero.exe` as their sandbox helper; release
|
||||
archives still ship standalone Windows helper executables.
|
||||
|
||||
More install details: [docs/INSTALL.md](docs/INSTALL.md).
|
||||
|
||||
## First Run
|
||||
|
||||
@@ -108,6 +108,36 @@ go build -o zero ./cmd/zero
|
||||
|
||||
Source builds require Go 1.25+.
|
||||
|
||||
### Sandbox Helpers For Source Builds
|
||||
|
||||
Release archives include the platform sandbox helpers. If you build directly
|
||||
from source, build the helpers you need:
|
||||
|
||||
Linux:
|
||||
|
||||
```bash
|
||||
go build -o zero ./cmd/zero
|
||||
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
|
||||
go build -o zero-seccomp ./cmd/zero-seccomp
|
||||
```
|
||||
|
||||
Put `zero` and `zero-linux-sandbox` in the same directory on `PATH`, for example
|
||||
`~/.local/bin`. `zero-seccomp` is kept as a compatibility wrapper; the sandbox
|
||||
helper applies the Unix-socket filter itself when that sandbox option is enabled.
|
||||
Linux native sandboxing also requires Bubblewrap to be installed.
|
||||
|
||||
macOS uses the system sandbox and does not need an extra helper binary.
|
||||
|
||||
Windows source builds can use the main `zero.exe` as the command runner and setup
|
||||
helper through Zero's built-in self-dispatch path. If you want a release-style
|
||||
layout anyway, build the standalone helper executables next to `zero.exe`:
|
||||
|
||||
```powershell
|
||||
go build -o zero.exe ./cmd/zero
|
||||
go build -o zero-windows-command-runner.exe ./cmd/zero-windows-command-runner
|
||||
go build -o zero-windows-sandbox-setup.exe ./cmd/zero-windows-sandbox-setup
|
||||
```
|
||||
|
||||
## Release Archive Format
|
||||
|
||||
Release archives are named:
|
||||
|
||||
@@ -59,6 +59,7 @@ const (
|
||||
responsesEventOutputItemAdded = "response.output_item.added"
|
||||
responsesEventContentPartAdded = "response.content_part.added"
|
||||
responsesEventOutputTextDelta = "response.output_text.delta"
|
||||
responsesEventReasoningDelta = "response.reasoning_summary_text.delta"
|
||||
responsesEventOutputTextDone = "response.output_text.done"
|
||||
responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
|
||||
responsesEventContentPartDone = "response.content_part.done"
|
||||
@@ -86,6 +87,10 @@ type responsesRequest struct {
|
||||
// here; omitted entirely when the caller requests no (or an unsupported) effort.
|
||||
type responsesReasoning struct {
|
||||
Effort string `json:"effort,omitempty"`
|
||||
// Summary requests a streamed reasoning summary ("auto" lets the API pick a
|
||||
// level). Without it the backend emits no reasoning events, so a long thinking
|
||||
// phase produces zero visible output and reads as a hang in the UI.
|
||||
Summary string `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
// inputItem is one element of the Responses `input` array. The Type field
|
||||
@@ -256,7 +261,9 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
|
||||
// and an empty or unsupported effort simply omits the field — without this the
|
||||
// caller's chosen effort was silently dropped for every Codex model.
|
||||
if effort := openAIReasoningEffort(request.ReasoningEffort); effort != "" {
|
||||
req.Reasoning = &responsesReasoning{Effort: effort}
|
||||
// Summary "auto" makes the backend stream reasoning_summary_text deltas so a
|
||||
// long thinking phase shows live progress instead of looking hung.
|
||||
req.Reasoning = &responsesReasoning{Effort: effort, Summary: "auto"}
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
@@ -501,6 +508,17 @@ func (p *CodexProvider) emitResponsesEvent(
|
||||
})
|
||||
}
|
||||
return true
|
||||
case responsesEventReasoningDelta:
|
||||
// Reasoning summary deltas: surface as live "thinking" so a long reasoning
|
||||
// phase shows progress (and keeps the activity clock fresh) instead of
|
||||
// looking like a hang. Requested via reasoning.summary="auto".
|
||||
if event.Delta != "" {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventReasoning,
|
||||
Content: event.Delta,
|
||||
})
|
||||
}
|
||||
return true
|
||||
case responsesEventFunctionArgsDelta:
|
||||
p.handleFunctionArgsDelta(ctx, &event, state, events)
|
||||
return true
|
||||
|
||||
@@ -485,6 +485,58 @@ func TestCodexProviderForwardsReasoningEffort(t *testing.T) {
|
||||
if reasoning["effort"] != "high" {
|
||||
t.Fatalf("body.reasoning.effort = %#v, want high", reasoning["effort"])
|
||||
}
|
||||
// A reasoning summary must be requested so the backend streams
|
||||
// reasoning_summary_text deltas — otherwise a long think shows no live output.
|
||||
if reasoning["summary"] != "auto" {
|
||||
t.Fatalf("body.reasoning.summary = %#v, want auto", reasoning["summary"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProviderStreamsReasoningSummaryDeltas(t *testing.T) {
|
||||
// reasoning_summary_text deltas must surface as StreamEventReasoning (live
|
||||
// "thinking"), in order, alongside the normal text output. Without this a long
|
||||
// reasoning phase produces zero visible output and reads as a hang.
|
||||
var rec codexRequest
|
||||
srv := newCodexResponsesServer(t, &rec,
|
||||
`{"type":"response.created","response":{"id":"resp-1","status":"in_progress"}}`,
|
||||
`{"type":"response.reasoning_summary_text.delta","delta":"Thinking. "}`,
|
||||
`{"type":"response.reasoning_summary_text.delta","delta":"Planning."}`,
|
||||
`{"type":"response.output_text.delta","delta":"done"}`,
|
||||
`{"type":"response.completed","response":{"id":"resp-1","status":"completed"}}`,
|
||||
)
|
||||
defer srv.Close()
|
||||
|
||||
provider, err := NewCodexProvider(CodexOptions{
|
||||
Options: Options{APIKey: "sk-test", BaseURL: srv.URL, Model: "gpt-5"},
|
||||
AccountID: "acc-x",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCodexProvider: %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "solve it"}},
|
||||
ReasoningEffort: "high",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion: %v", err)
|
||||
}
|
||||
var reasoning, text []string
|
||||
for ev := range stream {
|
||||
switch ev.Type {
|
||||
case zeroruntime.StreamEventReasoning:
|
||||
reasoning = append(reasoning, ev.Content)
|
||||
case zeroruntime.StreamEventText:
|
||||
text = append(text, ev.Content)
|
||||
case zeroruntime.StreamEventError:
|
||||
t.Fatalf("unexpected error event: %q", ev.Error)
|
||||
}
|
||||
}
|
||||
if got := strings.Join(reasoning, ""); got != "Thinking. Planning." {
|
||||
t.Fatalf("reasoning deltas = %q, want %q", got, "Thinking. Planning.")
|
||||
}
|
||||
if got := strings.Join(text, ""); got != "done" {
|
||||
t.Fatalf("text deltas = %q, want %q", got, "done")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProviderOmitsReasoningWhenUnset(t *testing.T) {
|
||||
|
||||
@@ -385,6 +385,14 @@ func sendBufferedEvent(events chan<- zeroruntime.StreamEvent, event zeroruntime.
|
||||
func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) chatCompletionRequest {
|
||||
messages := make([]chatMessage, 0, len(request.Messages))
|
||||
for _, message := range request.Messages {
|
||||
// Drop a degenerate assistant turn that carries neither text nor tool calls
|
||||
// (e.g. a sub-agent that failed with no output). The Anthropic/Gemini mappers
|
||||
// already skip empty turns; without this, the contentless message reaches
|
||||
// strict OpenAI-compatible servers and is rejected.
|
||||
if message.Role == zeroruntime.MessageRoleAssistant &&
|
||||
strings.TrimSpace(message.Content) == "" && len(message.ToolCalls) == 0 {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, mapMessage(message))
|
||||
}
|
||||
|
||||
@@ -445,11 +453,12 @@ func mapMessage(message zeroruntime.Message) chatMessage {
|
||||
// message that happens to carry Images keeps the plain string/nil content
|
||||
// path (its images are simply not serialized).
|
||||
if len(message.Images) == 0 || message.Role != zeroruntime.MessageRoleUser {
|
||||
// Preserve the legacy behavior: only non-empty text is serialized, so
|
||||
// empty content is omitted by the `omitempty` tag.
|
||||
if message.Content != "" {
|
||||
mapped.Content = message.Content
|
||||
}
|
||||
// Always set content (to "" when empty) so it serializes as `"content":""`
|
||||
// rather than being dropped. Strict OpenAI-compatible servers reject a
|
||||
// message with no content field; tool results and assistant-with-tool-calls
|
||||
// turns must still carry an (empty) content. Truly empty assistant turns are
|
||||
// dropped upstream in openAIRequest.
|
||||
mapped.Content = message.Content
|
||||
} else {
|
||||
parts := make([]contentPart, 0, len(message.Images)+1)
|
||||
if message.Content != "" {
|
||||
|
||||
@@ -1000,57 +1000,6 @@ func TestStreamCompletionEmitsDroppedOnNamelessToolCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIRequestTextOnlyOmitsEmptyContent(t *testing.T) {
|
||||
provider, err := New(Options{Model: "gpt-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
got, err := json.Marshal(provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "hello"},
|
||||
{
|
||||
Role: zeroruntime.MessageRoleAssistant,
|
||||
Content: "", // assistant turn that is *only* a tool call -> empty content must drop
|
||||
ToolCalls: []zeroruntime.ToolCall{{
|
||||
ID: "call_1",
|
||||
Name: "read_file",
|
||||
Arguments: `{"path":"README.md"}`,
|
||||
}},
|
||||
},
|
||||
{Role: zeroruntime.MessageRoleTool, Content: "contents", ToolCallID: "call_1"},
|
||||
},
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
|
||||
// String content round-trips as a JSON string, and empty content is omitted.
|
||||
if !strings.Contains(string(got), `"content":"hello"`) {
|
||||
t.Fatalf("user content not serialized as string: %s", got)
|
||||
}
|
||||
if strings.Contains(string(got), `"content":""`) {
|
||||
t.Fatalf("empty assistant content must be omitted, got: %s", got)
|
||||
}
|
||||
|
||||
// Decode the assistant message and assert no content key at all.
|
||||
var decoded chatCompletionRequest
|
||||
var raw struct {
|
||||
Messages []map[string]any `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(got, &raw); err != nil {
|
||||
t.Fatalf("unmarshal request: %v", err)
|
||||
}
|
||||
_ = decoded
|
||||
if _, present := raw.Messages[2]["content"]; present {
|
||||
t.Fatalf("assistant (tool-only) message must omit content key, got: %#v", raw.Messages[2])
|
||||
}
|
||||
if raw.Messages[1]["content"] != "hello" {
|
||||
t.Fatalf("user content = %#v, want \"hello\"", raw.Messages[1]["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentPartImageURLMarshalsDataURI(t *testing.T) {
|
||||
parts := []contentPart{
|
||||
{Type: "text", Text: "look"},
|
||||
@@ -1076,38 +1025,6 @@ func TestContentPartImageURLMarshalsDataURI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIRequestEmptyContentStaysOmittedAfterAnyRefactor(t *testing.T) {
|
||||
provider, err := New(Options{Model: "gpt-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
got, err := json.Marshal(provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{
|
||||
Role: zeroruntime.MessageRoleAssistant,
|
||||
Content: "",
|
||||
ToolCalls: []zeroruntime.ToolCall{{
|
||||
ID: "call_1",
|
||||
Name: "read_file",
|
||||
Arguments: `{}`,
|
||||
}},
|
||||
},
|
||||
},
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var raw struct {
|
||||
Messages []map[string]any `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(got, &raw); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if _, present := raw.Messages[0]["content"]; present {
|
||||
t.Fatalf("empty content boxed in any must stay omitted, got: %#v", raw.Messages[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapMessageBuildsImageURLContentParts(t *testing.T) {
|
||||
msg := mapMessage(zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
@@ -1165,8 +1082,8 @@ func TestMapMessageTextOnlyKeepsStringContent(t *testing.T) {
|
||||
t.Fatalf("Content = %#v, want string \"hi\"", msg.Content)
|
||||
}
|
||||
empty := mapMessage(zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: ""})
|
||||
if empty.Content != nil {
|
||||
t.Fatalf("empty text content = %#v, want nil so omitempty drops it", empty.Content)
|
||||
if got, ok := empty.Content.(string); !ok || got != "" {
|
||||
t.Fatalf("empty text content = %#v, want \"\" so it serializes as content:\"\" (strict servers reject a missing/null content)", empty.Content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1246,3 +1163,66 @@ func TestStreamCompletionSerializesImageContentParts(t *testing.T) {
|
||||
t.Fatalf("image url = %#v, want data:image/png;base64,QUJD", imageURL["url"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIRequestEmptyContentHandling locks in the fix for strict OpenAI-
|
||||
// compatible servers (e.g. glm-* on Ollama-cloud) that reject a message whose
|
||||
// `content` is absent/null with "invalid message content type: <nil>": a
|
||||
// contentless assistant turn with no tool calls is dropped, and every other
|
||||
// message serializes an explicit `"content"` field (empty string when there is
|
||||
// no text) instead of omitting it.
|
||||
func TestOpenAIRequestEmptyContentHandling(t *testing.T) {
|
||||
provider, err := New(Options{Model: "gpt-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
req := provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "hi"},
|
||||
// Degenerate empty assistant turn (e.g. a sub-agent that failed with no
|
||||
// output): must be dropped, not sent.
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: " "},
|
||||
// Assistant with tool calls but no text: kept, content present as "".
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "", ToolCalls: []zeroruntime.ToolCall{{
|
||||
ID: "call_1", Name: "read_file", Arguments: "{}",
|
||||
}}},
|
||||
// Tool result with empty content: kept, content present as "".
|
||||
{Role: zeroruntime.MessageRoleTool, Content: "", ToolCallID: "call_1"},
|
||||
},
|
||||
})
|
||||
|
||||
if len(req.Messages) != 3 {
|
||||
t.Fatalf("empty assistant turn should be dropped; got %d messages: %#v", len(req.Messages), req.Messages)
|
||||
}
|
||||
|
||||
// No message may serialize with an absent/null content field.
|
||||
for i, message := range req.Messages {
|
||||
if message.Content == nil {
|
||||
t.Fatalf("message %d has nil content (would serialize as null/omitted): %#v", i, message)
|
||||
}
|
||||
data, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal message %d: %v", i, err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"content":`) {
|
||||
t.Fatalf("message %d omits the content field: %s", i, data)
|
||||
}
|
||||
}
|
||||
|
||||
if req.Messages[0].Content != "hi" {
|
||||
t.Fatalf("user content = %#v, want \"hi\"", req.Messages[0].Content)
|
||||
}
|
||||
// The kept-but-textless messages must send content as an explicit empty string.
|
||||
for _, idx := range []int{1, 2} {
|
||||
data, err := json.Marshal(req.Messages[idx])
|
||||
if err != nil {
|
||||
t.Fatalf("marshal message %d: %v", idx, err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"content":""`) {
|
||||
t.Fatalf("message %d should send content:\"\", got %s", idx, data)
|
||||
}
|
||||
}
|
||||
if len(req.Messages[1].ToolCalls) != 1 {
|
||||
t.Fatalf("assistant tool calls dropped: %#v", req.Messages[1])
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,13 @@ type streamOptions struct {
|
||||
}
|
||||
|
||||
type chatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content,omitempty"`
|
||||
Role string `json:"role"`
|
||||
// Content has no omitempty: strict OpenAI-compatible servers (e.g. some
|
||||
// Ollama-cloud models like glm-*) reject a message whose `content` is absent
|
||||
// or null with "invalid message content type: <nil>". mapMessage always sets
|
||||
// this (to "" when there's no text), so a contentless message serializes as
|
||||
// `"content":""` rather than being dropped.
|
||||
Content any `json:"content"`
|
||||
ToolCalls []requestToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
@@ -261,13 +261,11 @@ func (m model) swarmSpawnedAgents() []swarmAgent {
|
||||
switch a.state {
|
||||
case "done", "failed", "completed", "cancelled":
|
||||
a.finishing = true
|
||||
if m.pending {
|
||||
// Run still going: keep it visible and clickable, no fade.
|
||||
a.finishedAt = time.Time{}
|
||||
live = append(live, a)
|
||||
continue
|
||||
}
|
||||
// Turn ended: fade out over the linger window, then drop.
|
||||
// A member drops once ITS OWN task completes — not when the whole turn
|
||||
// ends: fade out over the linger window from when it was first seen
|
||||
// finished (stamped each tick by stampSwarmDone), then remove. This
|
||||
// holds whether or not the overall run is still in flight, mirroring
|
||||
// how finished specialists drop (sidebarSpecialists).
|
||||
doneAt, stamped := m.swarmDoneAt[a.id]
|
||||
if stamped && m.now().Sub(doneAt) >= sidebarAgentLinger {
|
||||
continue // past the linger window — remove
|
||||
|
||||
@@ -391,32 +391,32 @@ func TestSidebarShowsSwarmSpawnedAgents(t *testing.T) {
|
||||
// row (e.g. a resumed transcript that dropped the call): the member is still
|
||||
// A finished member stays visible (and clickable) while the run is still in
|
||||
// flight, so the user can inspect it; only once the turn ends does it drop.
|
||||
func TestSwarmAgentsPersistWhileRunInFlight(t *testing.T) {
|
||||
func TestSwarmAgentDropsOnOwnCompletionEvenMidRun(t *testing.T) {
|
||||
base := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
|
||||
m := sidebarTestModel()
|
||||
m.now = func() time.Time { return base }
|
||||
m.pending = true // run still going
|
||||
m.pending = true // run STILL going — a member must still drop on its OWN completion
|
||||
m.activeRunID = 7 // exercise the run-scoped filter with a non-zero id
|
||||
m.transcript = append(m.transcript,
|
||||
transcriptRow{kind: rowToolCall, tool: "swarm_spawn", detail: "build homepage", runID: 7},
|
||||
transcriptRow{kind: rowToolResult, tool: "swarm_spawn", detail: "Spawned subagent as task subagent-1 on team default.", runID: 7},
|
||||
transcriptRow{kind: rowToolResult, tool: "swarm_collect", detail: "Results: 1 task(s)\n- subagent-1 [done] build homepage", runID: 7},
|
||||
)
|
||||
// Long past the linger window — but it must still show while pending.
|
||||
m.swarmDoneAt = map[string]time.Time{"subagent-1": base.Add(-10 * sidebarAgentLinger)}
|
||||
|
||||
// Just finished, still within the linger window: shows briefly with a fading ✓.
|
||||
m.swarmDoneAt = map[string]time.Time{"subagent-1": base.Add(-sidebarAgentLinger / 2)}
|
||||
agents := m.swarmSpawnedAgents()
|
||||
if len(agents) != 1 {
|
||||
t.Fatalf("a finished member must stay while the run is in flight, got %d: %+v", len(agents), agents)
|
||||
t.Fatalf("within the linger window a finished member should still show (fading), got %d: %+v", len(agents), agents)
|
||||
}
|
||||
if !agents[0].finishing {
|
||||
t.Fatalf("a finished member should render done (✓), got %+v", agents[0])
|
||||
}
|
||||
|
||||
// Once the turn ends, the long-finished member fades out and drops.
|
||||
m.pending = false
|
||||
// Past the linger window: it drops — even though the overall run is still in
|
||||
// flight (previously a finished member lingered until the whole turn ended).
|
||||
m.swarmDoneAt = map[string]time.Time{"subagent-1": base.Add(-2 * sidebarAgentLinger)}
|
||||
if got := len(m.swarmSpawnedAgents()); got != 0 {
|
||||
t.Fatalf("after the turn ends a long-finished member should drop, got %d", got)
|
||||
t.Fatalf("a member past its linger window must drop on its own completion mid-run, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user