mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
fix(control): tell the model its scratch directory is $TMPDIR and needs no cleanup
The host already confines bash's $TMPDIR to a per-session directory, the file tools resolve a leading $TMPDIR to it, and the host deletes it when the session ends. The model was never told the last two, so it verified with `scratch=$(mktemp -d); ...; rm -rf "$scratch"` or wrote tests to /tmp, and the host refused them (shell.delete_nonliteral, shell.delete_sequence, write scope): 26 refused calls over 36 deepseek-flash benchmark runs, one model round each. A <session-scratch> turn block now says: put throwaway files at `$TMPDIR/<name>`, spelled that way in bash and in the file tools, and do not delete them. The host path is deliberately not projected: under bwrap bash sees the directory at /tmp, and on Windows a backslashed literal does not survive a bash command line, so $TMPDIR is the one spelling every tool resolves. The block follows the dateAnchor/projectionDebt pattern: it rides the turn and never the prefix, settles only when a turn reaches the runner, is re-owed after a fold, retracts itself once if the directory can no longer be created, is projected only for a manager the assembly supplied, and is inherited across a rebuild. Its bytes carry no per-session value, so two sessions over the same state project identically. No shell-safety rule changes; the effect test asserts that a recursive delete through $TMPDIR or a mktemp variable is still refused.
This commit is contained in:
1 parent
d2f919736b
commit
c8c8d8e341
13 files changed
+358
-5
No files matched your search
@@ -291,6 +291,14 @@ describe("rebuilding a reopened transcript", () => {
|
||||
expect(got[0].text).toBe("继续");
|
||||
});
|
||||
|
||||
it("strips a session-scratch block from a replayed turn", () => {
|
||||
const got = users([
|
||||
{ role: "user", content: "<session-scratch>\nThis session has a private scratch directory, $TMPDIR.\n</session-scratch>\n继续" },
|
||||
]);
|
||||
expect(got).toHaveLength(1);
|
||||
expect(got[0].text).toBe("继续");
|
||||
});
|
||||
|
||||
it("leaves an ordinary turn as its text", () => {
|
||||
const got = users([{ role: "user", content: "第一句话" }]);
|
||||
expect(got).toHaveLength(1);
|
||||
|
||||
@@ -651,7 +651,7 @@ function withReceipt(items: Item[], r?: Receipt): Item[] {
|
||||
// eleven. The server strips these before /history now — this is what covers
|
||||
// sessions already on disk.
|
||||
const CONTROL =
|
||||
/<(reasoning-language|turn-narration|response-language|execution-policy|memory-update|background-jobs|active-goal|autoresearch-runtime|hook-context|available-skills|current-date|project-instructions|project-rules|scoped-rules|capability-route|interrupted-turn-recovery|workspace|scheduled-run|mcp-prompt-failure|refused-calls)[\s\S]*?<\/\1>\s*/g;
|
||||
/<(reasoning-language|turn-narration|response-language|execution-policy|memory-update|background-jobs|active-goal|autoresearch-runtime|hook-context|available-skills|current-date|session-scratch|project-instructions|project-rules|scoped-rules|capability-route|interrupted-turn-recovery|workspace|scheduled-run|mcp-prompt-failure|refused-calls)[\s\S]*?<\/\1>\s*/g;
|
||||
const stripControl = (s: string) => s.replace(CONTROL, "").trim();
|
||||
|
||||
// A plan that ran to the end is spent: struck through in the rail it reads as
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/state/sessiontemp"
|
||||
)
|
||||
|
||||
// scratchProvider plays a model that verifies in the scratch directory it was
|
||||
// told about, spelling it exactly as the block says, one tool call per round.
|
||||
type scratchProvider struct {
|
||||
mu sync.Mutex
|
||||
reqs []provider.Request
|
||||
}
|
||||
|
||||
func (p *scratchProvider) Name() string { return "boot-effect-scratch" }
|
||||
|
||||
func (p *scratchProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
p.mu.Lock()
|
||||
p.reqs = append(p.reqs, req)
|
||||
round := len(p.reqs)
|
||||
told := round > 1 || strings.Contains(blockOf(lastUserContent(req), "session-scratch"), "`$TMPDIR/<name>`")
|
||||
p.mu.Unlock()
|
||||
|
||||
call := func(id, name string, args map[string]string) provider.Chunk {
|
||||
raw, _ := json.Marshal(args)
|
||||
return provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: id, Name: name, Arguments: string(raw)}}
|
||||
}
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
switch {
|
||||
case !told:
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "no scratch block was projected"}
|
||||
case round == 1:
|
||||
ch <- call("w1", "write_file", map[string]string{"path": "$TMPDIR/check/probe_test.py", "content": "assert True\n"})
|
||||
case round == 2:
|
||||
ch <- call("b1", "bash", map[string]string{"command": `cd "$TMPDIR/check" && cat probe_test.py`})
|
||||
case round == 3:
|
||||
ch <- call("b2", "bash", map[string]string{"command": `rm -rf "$TMPDIR/check"`})
|
||||
case round == 4:
|
||||
ch <- call("b3", "bash", map[string]string{"command": `scratch=$(mktemp -d); rm -rf "$scratch"`})
|
||||
default:
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "done"}
|
||||
}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func lastUserContent(req provider.Request) string {
|
||||
for _, m := range slices.Backward(req.Messages) {
|
||||
if m.Role == provider.RoleUser {
|
||||
return m.Content
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *scratchProvider) toolResult(round int, id string) string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if round >= len(p.reqs) {
|
||||
return ""
|
||||
}
|
||||
for _, m := range p.reqs[round].Messages {
|
||||
if m.Role == provider.RoleTool && m.ToolCallID == id {
|
||||
return m.Content
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestEffectSessionScratchIsReachableByItsSpelling pins what the scratch block
|
||||
// buys at the tool boundary: the one spelling it gives, `$TMPDIR/<name>`, reaches
|
||||
// the same directory through write_file and through bash, the host path never
|
||||
// reaches the model, and the deletes the gate exists to refuse are still
|
||||
// refused for the scratch directory itself.
|
||||
func TestEffectSessionScratchIsReachableByItsSpelling(t *testing.T) {
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
rec := &scratchProvider{}
|
||||
resolver := &provider.StaticResolver{
|
||||
Descriptors: []provider.Descriptor{{Ref: "test-model/x", Model: "x", Default: true}},
|
||||
Providers: map[string]provider.Provider{"test-model/x": rec},
|
||||
}
|
||||
writeUserConfig(t, "[sandbox]\nbash = \"off\"\n")
|
||||
writeFile(t, dir, "reasonix.toml", "\n[agent]\nsystem_prompt = \"BASE\"\n")
|
||||
ctrl, err := Build(context.Background(), Options{Sink: event.Discard, Model: "test-model/x", ProviderResolver: resolver, resolvedShell: pinnedEffectShell()})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
ctrl.SetSessionPath(filepath.Join(dir, ".reasonix", "sessions", "scratch.jsonl"))
|
||||
ctrl.SetBypass(true)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
if err := ctrl.Run(ctx, "verify in scratch"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
rec.mu.Lock()
|
||||
first := rec.reqs[0]
|
||||
rec.mu.Unlock()
|
||||
scratch := ctrl.SessionTemp().Dir()
|
||||
if scratch == "" {
|
||||
t.Fatal("the session has no scratch directory")
|
||||
}
|
||||
for _, m := range first.Messages {
|
||||
if strings.Contains(m.Content, scratch) || strings.Contains(m.Content, sessiontemp.Prefix()) {
|
||||
t.Fatalf("the host scratch path reached the model in a %s message", m.Role)
|
||||
}
|
||||
}
|
||||
if got := rec.toolResult(1, "w1"); strings.Contains(got, "refused") {
|
||||
t.Fatalf("write_file to $TMPDIR/check was refused: %s", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(scratch, "check", "probe_test.py")); err != nil {
|
||||
t.Fatalf("write_file did not land in the session's directory: %v", err)
|
||||
}
|
||||
if got := rec.toolResult(2, "b1"); strings.Contains(got, "refused") || !strings.Contains(got, "assert True") {
|
||||
t.Fatalf(`bash did not reach the file write_file wrote through "$TMPDIR/check": %s`, got)
|
||||
}
|
||||
if got := rec.toolResult(3, "b2"); !strings.Contains(got, "refused") {
|
||||
t.Fatalf("a recursive delete through $TMPDIR was not refused: %s", got)
|
||||
}
|
||||
if got := rec.toolResult(4, "b3"); !strings.Contains(got, "refused") {
|
||||
t.Fatalf("a delete through a mktemp variable was not refused: %s", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(scratch, "check", "probe_test.py")); err != nil {
|
||||
t.Fatalf("a refused delete still removed the scratch file: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,7 @@ func ApplyRuntimeMigration(ctrl, old *control.Controller, m RuntimeMigration) er
|
||||
ctrl.CarryRecoveryFrom(old)
|
||||
}
|
||||
ctrl.InheritLifecycleFrom(old)
|
||||
ctrl.InheritScratchFrom(old)
|
||||
}
|
||||
ctrl.RestoreSessionAuthorizations(m.authorizations)
|
||||
// A mode survives only onto a model that declares it: switching to one
|
||||
|
||||
@@ -81,6 +81,7 @@ type controllerDeps struct {
|
||||
skills skillSet
|
||||
rules rulesSet
|
||||
date dateAnchor
|
||||
scratch scratchAnchor
|
||||
skillRunner skill.SubagentRunner
|
||||
side sideSet // see side_session.go
|
||||
readOnlySkillRunner skill.SubagentRunner
|
||||
@@ -179,6 +180,7 @@ func newControllerDeps(opts Options, sink event.Sink, usageTee *goalUsageTee, ru
|
||||
modelFace: faceOfEntry(opts.ModelEntry),
|
||||
sessionDir: opts.SessionDir,
|
||||
date: newDateAnchor(opts.Clock),
|
||||
scratch: scratchAnchor{temp: opts.SessionTemp},
|
||||
skills: newSkillSet(opts.Skills, opts.AllSkills, opts.SkillStore, opts.AllSkillStore, opts.DisableImplicitSkillInvocation, opts.WorkspaceRoot),
|
||||
skillRunner: opts.SkillRunner,
|
||||
side: sideSet{factory: opts.SideFactory},
|
||||
|
||||
@@ -45,6 +45,7 @@ func (s *inboxEventSink) Emit(e event.Event) {
|
||||
return
|
||||
}
|
||||
s.c.date.debt.forget()
|
||||
s.c.scratch.debt.forget()
|
||||
s.c.skills.forgetDeliveredCatalog()
|
||||
s.c.memory.forgetDeliveredInstructions()
|
||||
s.c.rules.forget()
|
||||
|
||||
@@ -46,6 +46,13 @@ func (d *projectionDebt) sent() bool {
|
||||
return prev != nil && *prev != ""
|
||||
}
|
||||
|
||||
// inherit takes over what another controller's model was told, for a
|
||||
// replacement that carries that conversation verbatim.
|
||||
func (d *projectionDebt) inherit(from *projectionDebt) {
|
||||
d.pending.Store(nil)
|
||||
d.delivered.Store(from.delivered.Load())
|
||||
}
|
||||
|
||||
// forget returns the debt to unknown: what was sent is no longer in the context
|
||||
// the model samples from. A new session starts here, and so does a fold — which
|
||||
// also discards a turn's pending bytes, since the fold decided their fate.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package control
|
||||
|
||||
import "reasonix/internal/state/sessiontemp"
|
||||
|
||||
const sessionScratchTag = "session-scratch"
|
||||
|
||||
const scratchGuidance = "This session has a private scratch directory, $TMPDIR, which the host deletes when the session ends. " +
|
||||
"Put throwaway tests and probes at `$TMPDIR/<name>`, spelled that way in bash (quoted) and in the file tools, " +
|
||||
"instead of the repository, /tmp or `mktemp -d`, and do not delete it."
|
||||
|
||||
const scratchRetracted = "No session scratch directory is available now; do not rely on $TMPDIR for scratch files."
|
||||
|
||||
// scratchAnchor tells the model the one spelling of its scratch directory that
|
||||
// bash and the file tools both resolve. The host path differs per platform and
|
||||
// per sandbox (bwrap binds the directory at /tmp), so $TMPDIR is the identity
|
||||
// and the path is never projected. Only a manager the assembly supplied counts:
|
||||
// it is the one the tools are bound to.
|
||||
type scratchAnchor struct {
|
||||
temp *sessiontemp.Manager
|
||||
debt projectionDebt
|
||||
}
|
||||
|
||||
// InheritScratchFrom carries what prev's model was told across a rebuild that
|
||||
// keeps its conversation, so an unchanged block is not sent twice.
|
||||
func (c *Controller) InheritScratchFrom(prev *Controller) {
|
||||
if prev == nil {
|
||||
return
|
||||
}
|
||||
c.scratch.debt.inherit(&prev.scratch.debt)
|
||||
}
|
||||
|
||||
// owed asks the manager rather than waiting to be told: a generation that can
|
||||
// no longer be created changes the answer, and noticing is the projection's job.
|
||||
func (s *scratchAnchor) owed() string {
|
||||
if scratchDir(s.temp) != "" {
|
||||
return s.debt.owed(scratchGuidance)
|
||||
}
|
||||
if !s.debt.sent() {
|
||||
return s.debt.owed("")
|
||||
}
|
||||
return s.debt.owed(scratchRetracted)
|
||||
}
|
||||
|
||||
// scratchDir names the current generation, creating it when no command has yet:
|
||||
// a directory the model is told about must exist before its first write.
|
||||
func scratchDir(m *sessiontemp.Manager) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
if dir := m.Dir(); dir != "" {
|
||||
return dir
|
||||
}
|
||||
lease, err := m.Acquire()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer lease.Release()
|
||||
return lease.Dir()
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/state/sessiontemp"
|
||||
)
|
||||
|
||||
func scratchBlockOf(composed string) string {
|
||||
open, close := "<"+sessionScratchTag+">", "</"+sessionScratchTag+">"
|
||||
i := strings.Index(composed, open)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
j := strings.Index(composed[i:], close)
|
||||
if j < 0 {
|
||||
return composed[i:]
|
||||
}
|
||||
return composed[i : i+j+len(close)]
|
||||
}
|
||||
|
||||
// The model is told the one spelling bash and the file tools both resolve. The
|
||||
// host path is never projected: under bwrap bash sees the directory at /tmp, and
|
||||
// on Windows a backslashed literal does not survive a bash command line.
|
||||
func TestScratchBlockProjectsTheSpellingNotTheHostPath(t *testing.T) {
|
||||
m := sessiontemp.NewWithRoot(testenv.TempDir(t))
|
||||
c := New(Options{SessionTemp: m, Sink: event.Discard})
|
||||
defer c.Close()
|
||||
|
||||
first := scratchBlockOf(c.Compose("turn-one"))
|
||||
dir := m.Dir()
|
||||
if dir == "" {
|
||||
t.Fatal("projecting the scratch block did not create the session's directory")
|
||||
}
|
||||
if !strings.Contains(first, "`$TMPDIR/<name>`") {
|
||||
t.Fatalf("scratch block does not give the $TMPDIR spelling:\n%s", first)
|
||||
}
|
||||
if strings.Contains(first, dir) || strings.Contains(first, sessiontemp.Prefix()) {
|
||||
t.Fatalf("scratch block projected the host path %q:\n%s", dir, first)
|
||||
}
|
||||
c.settleTurnProjections()
|
||||
|
||||
if again := scratchBlockOf(c.Compose("turn-two")); again != "" {
|
||||
t.Fatalf("an unchanged block was projected twice:\n%s", again)
|
||||
}
|
||||
c.settleTurnProjections()
|
||||
|
||||
m.Rotate()
|
||||
if rotated := scratchBlockOf(c.Compose("turn-three")); rotated != "" {
|
||||
t.Fatalf("a rotation re-sent bytes the model already holds:\n%s", rotated)
|
||||
}
|
||||
}
|
||||
|
||||
// Composing alone settles nothing: a turn a hook blocks put the block nowhere.
|
||||
func TestScratchBlockIsOwedUntilATurnCarriesIt(t *testing.T) {
|
||||
m := sessiontemp.NewWithRoot(testenv.TempDir(t))
|
||||
c := New(Options{SessionTemp: m, Sink: event.Discard})
|
||||
defer c.Close()
|
||||
|
||||
if scratchBlockOf(c.Compose("blocked")) == "" {
|
||||
t.Fatal("first turn carried no scratch block")
|
||||
}
|
||||
if scratchBlockOf(c.Compose("retry")) == "" {
|
||||
t.Fatal("an unsettled scratch block was dropped from the next turn")
|
||||
}
|
||||
c.settleTurnProjections()
|
||||
c.scratch.debt.forget()
|
||||
if scratchBlockOf(c.Compose("after-fold")) == "" {
|
||||
t.Fatal("a forgotten delivery was not re-owed")
|
||||
}
|
||||
}
|
||||
|
||||
// A directory the model was told about and that can no longer be had — the
|
||||
// next generation failed to create — is retracted once, rather than left
|
||||
// reading as current.
|
||||
func TestScratchBlockRetractsADirectoryThatNoLongerHolds(t *testing.T) {
|
||||
m := sessiontemp.NewWithRoot(testenv.TempDir(t))
|
||||
c := New(Options{SessionTemp: m, Sink: event.Discard})
|
||||
defer c.Close()
|
||||
|
||||
if scratchBlockOf(c.Compose("one")) == "" {
|
||||
t.Fatal("first turn carried no scratch block")
|
||||
}
|
||||
c.settleTurnProjections()
|
||||
m.SetMkDirForTest(func(string) (string, error) { return "", errors.New("disk full") })
|
||||
m.Rotate()
|
||||
retract := scratchBlockOf(c.Compose("two"))
|
||||
if retract == "" || strings.Contains(retract, "`$TMPDIR/<name>`") {
|
||||
t.Fatalf("a directory that no longer holds was not retracted:\n%s", retract)
|
||||
}
|
||||
c.settleTurnProjections()
|
||||
if again := scratchBlockOf(c.Compose("three")); again != "" {
|
||||
t.Fatalf("the retraction was repeated:\n%s", again)
|
||||
}
|
||||
}
|
||||
|
||||
// Only the manager the assembly bound the tools to is announced; a controller
|
||||
// that made its own has no tool writing there.
|
||||
func TestScratchBlockNeedsTheAssemblysManager(t *testing.T) {
|
||||
c := New(Options{Sink: event.Discard})
|
||||
defer c.Close()
|
||||
if got := scratchBlockOf(c.Compose("one")); got != "" {
|
||||
t.Fatalf("a controller without an assembly-supplied manager announced one:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A rebuild carries the conversation verbatim, so a block it already holds is
|
||||
// not sent again, whichever manager the replacement is bound to.
|
||||
func TestScratchDeliverySurvivesARebuild(t *testing.T) {
|
||||
m := sessiontemp.NewWithRoot(testenv.TempDir(t))
|
||||
old := New(Options{SessionTemp: m, Sink: event.Discard})
|
||||
defer old.Close()
|
||||
if scratchBlockOf(old.Compose("one")) == "" {
|
||||
t.Fatal("first turn carried no scratch block")
|
||||
}
|
||||
old.settleTurnProjections()
|
||||
|
||||
for _, temp := range []*sessiontemp.Manager{m, sessiontemp.NewWithRoot(testenv.TempDir(t))} {
|
||||
next := New(Options{SessionTemp: temp, Sink: event.Discard})
|
||||
next.InheritScratchFrom(old)
|
||||
if got := scratchBlockOf(next.Compose("two")); got != "" {
|
||||
t.Fatalf("a rebuild re-sent the block:\n%s", got)
|
||||
}
|
||||
next.Close()
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ func (c *Controller) turnBlocksFor(source string, includeOwed bool, notes []stri
|
||||
blocks = append(blocks,
|
||||
turnBlock{hookContextTag, c.drainHookContextBlock()},
|
||||
turnBlock{currentDateTag, wrapTurnBlock(currentDateTag, c.date.owed())},
|
||||
turnBlock{sessionScratchTag, wrapTurnBlock(sessionScratchTag, c.scratch.owed())},
|
||||
turnBlock{"available-skills", wrapTurnBlock("available-skills", c.skills.owedCatalog())},
|
||||
turnBlock{"project-instructions", wrapTurnBlock("project-instructions", c.memory.owedInstructions())},
|
||||
turnBlock{"project-rules", wrapTurnBlock("project-rules", always)},
|
||||
@@ -170,6 +171,7 @@ func (c *Controller) runWithRunner(ctx context.Context, input string) error {
|
||||
|
||||
func (c *Controller) settleTurnProjections() {
|
||||
c.date.debt.settle()
|
||||
c.scratch.debt.settle()
|
||||
c.skills.catalog.settle()
|
||||
c.memory.instructions.settle()
|
||||
c.rules.settle()
|
||||
|
||||
@@ -20,6 +20,7 @@ func TestTurnBlockOrderIsDeclared(t *testing.T) {
|
||||
want := []string{
|
||||
"hook-context",
|
||||
"current-date",
|
||||
"session-scratch",
|
||||
"available-skills",
|
||||
"project-instructions",
|
||||
"project-rules",
|
||||
|
||||
@@ -27,6 +27,7 @@ var TransientUserBlockTags = []string{
|
||||
"hook-context",
|
||||
"available-skills",
|
||||
"current-date",
|
||||
"session-scratch",
|
||||
"project-instructions",
|
||||
"project-rules",
|
||||
"scoped-rules",
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
// duration of a logical chat session, while each bwrap/sandbox-exec invocation
|
||||
// still enters its own namespace.
|
||||
//
|
||||
// The manager is intentionally invisible to models and users: no settings,
|
||||
// tool parameters, or prompt surface is added. Hot rebuilds retain the same
|
||||
// Manager; /new, /clear, resume of another session, and branch switches rotate
|
||||
// to a fresh generation.
|
||||
// The manager adds no settings or tool parameters; its one model-visible
|
||||
// surface is the controller's turn block naming it as $TMPDIR, never its path. Hot
|
||||
// rebuilds retain the same Manager; /new, /clear, resume of another session,
|
||||
// and branch switches rotate to a fresh generation.
|
||||
package sessiontemp
|
||||
|
||||
import (
|
||||
|
||||
Reference in new issue
Block a user