From 766ce0f4f2db610336ad73949ede35fe2cbb49f2 Mon Sep 17 00:00:00 2001 From: "Eric A. Litman" Date: Tue, 4 Aug 2026 11:16:23 -0400 Subject: [PATCH 01/11] Fix migration concurrency and recovery --- INSTALL.md | 10 +- assets/AGENTS.threadbear.md | 2 + assets/skill/SKILL.md | 14 +-- cmd/threadbear/core_test.go | 121 ++++++++++++++++++++-- cmd/threadbear/install.go | 9 +- cmd/threadbear/install_test.go | 2 + cmd/threadbear/site_contract_test.go | 66 ++++++++++-- cmd/threadbear/state.go | 2 +- cmd/threadbear/uninstall_protocol_test.go | 8 +- site/install | 10 +- 10 files changed, 203 insertions(+), 41 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 60915ab..da75782 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -69,7 +69,7 @@ Only after every check and the dry run succeeds, compose one terminal final answ > - Codex limits title length limited to 60 UTF-16 units, so I'll truncate as needed. > - Small local footprint: one binary in ~/.local/bin, a skill, and two hooks. > - One persistent thread, 🧵🐻 ThreadBear 🐻🧵, for changing config and uninstalling. -> - Deterministic classification first; Luna medium only for ambiguity. +> - Deterministic classification and Luna-low ambiguity checks run in parallel. > - A small Luna helper checks in hourly, then stays quiet when there is nothing to do. > - Finished tasks can curl up in the archive after 14 quiet days—and come back whenever you need them. > - ThreadBear keeps itself fresh from verified releases and tells you when it has a new coat. @@ -123,7 +123,7 @@ Before migration, tell the user: Before any bulk work, use `codex_app__set_thread_title` to set the initiating task to exactly `🧵🐻 ThreadBear 🐻🧵`, use `codex_app__set_thread_pinned` to pin it, and keep this task selected. For an ordinary guided installation, do not use visual inspection, computer control, screenshots, or Codex `/hooks`, and do not ask the user to do so. The candidate self-test, installed `self-test --json`, and read-only ThreadBear inventory are the installation gate; visual hook verification is outside this ordinary installation flow. -Create exactly one background migration-controller task with a prompt containing the controller protocol from the installed ThreadBear skill. Do not open, select, or navigate to it. On a compatible machine with the candidate already downloaded, dispatch it within 60 seconds of consent; do not insert UI inspection, App Server schema generation, native-catalog comparison, or tool-surface discovery before dispatch. If creation fails, leave the truthful `migration_pending` phase and report that nothing is running plus the exact retry action. After successful creation, immediately persist its exact ID: +Create exactly one projectless background migration-controller task with `codex_app__create_thread` using `model:"gpt-5.6-terra"`, `thinking:"medium"`, and a prompt containing the controller protocol from the installed ThreadBear skill. Do not open, select, or navigate to it. On a compatible machine with the candidate already downloaded, dispatch it within 60 seconds of consent; do not insert UI inspection, App Server schema generation, native-catalog comparison, or tool-surface discovery before dispatch. If creation fails, leave the truthful `migration_pending` phase and report that nothing is running plus the exact retry action. After successful creation, immediately persist its exact ID: ```sh ~/.local/bin/threadbear migration \ @@ -131,7 +131,9 @@ Create exactly one background migration-controller task with a prompt containing --controller-task-id "$CONTROLLER_TASK_ID" --json ``` -The controller is the only migration writer. It processes one explicit target at a time and skips only rows already reporting `applied:true`. When deterministic rows exist, it begins a stable batch of at most 25 before discovering or spawning Luna workers, with the first title mutation issued within 60 seconds of controller start and within 15 seconds of the inventory result. It then uses the fixed `codex_app__create_thread` surface with Luna medium to launch adaptive waves of fresh read-only Luna-medium workers for genuinely ambiguous classifications while deterministic writes continue serially. Every successful worker handle is recorded and awaited even if a later spawn hits the agent-capacity limit; results may arrive out of order, but title writes remain serial. A completed ambiguous classification uses the exact hook-accepted marker `❔ ThreadBear could not classify`, never an invented compact unknown footer. Each worker has an eight-minute deadline, a timed-out read-only batch gets one bounded retry, and the controller never starts another wave or returns while a retained worker is active or unaccounted for. If zero workers can start for two minutes, or a retry also times out, it records `migration_failed`. Every non-successful exit records `migration_failed` before returning; it never leaves an idle controller described as `migration_running`. A timeout or unknown native title result remains fail-closed until authoritative inventory reconciliation. On success it performs a final inventory with zero remaining rows, then records: +The controller is the only migration writer. It processes explicit targets in bounded concurrent waves of at most eight distinct task IDs and skips only rows already reporting `applied:true`. Immediately before each title wave it concurrently calls `codex_app__read_thread` for every target as a read-only activation gate, requires each exact task ID and inventory-planned title, and starts the setter wave within 15 seconds without navigating or selecting; a failed or mismatched read is reconciled through fresh inventory and never followed by a blind write. From the first inventory snapshot, it starts the first stable deterministic batch of at most 25 and one bounded concurrent spawn wave of fresh read-only Luna-low workers in stable batches of at most 10 genuinely ambiguous tasks at the same time, with the first title mutation issued within 60 seconds of controller start and within 15 seconds of the inventory result. Worker creation uses the fixed `codex_app__create_thread` surface with `model:"gpt-5.6-luna"` and `thinking:"low"`, records every settled successful handle, and never lets a failed spawn hide an earlier success. The controller derives every assigned list mechanically from the parsed inventory `task_id` fields, never retypes or synthesizes an ID, and asserts exact membership in the same inventory snapshot before spawning. Every successful worker handle is recorded and awaited even if a later spawn hits the agent-capacity limit; retained classifier IDs are excluded from title waves, and all currently validated workers are archived together in one concurrent native explicit-target call before their batches are applied. A missing, mismatched, or unconfirmed worker archive fails closed. Classifier results may arrive out of order. The classifier's `phase:"final_answer"` item must contain one JSON array with every assigned ID exactly once and no other ID, followed only by the one terminal ThreadBear status line required by the managed block; separate worker commentary is not part of the result grammar. Because a `wait_threads` snapshot may normalize the one separator newline into a space, the controller locates the array's closing bracket and accepts one or more whitespace characters before the exact footer instead of requiring a physical newline; it still rejects any other prose. The controller ignores the footer only while parsing the array. Any other final-answer prose, malformed content, or mismatched batch is discarded and retried once without writing titles. The controller passes the compact footer grammar—not a subject, visible status icon, or pre-rendered title—so the Pre hook alone expands the authoritative subject. Every footer setter value begins with the full literal `🧵🐻` mark (`title:"🧵🐻 complete"`, for example); bare classification labels such as `complete`, `blocked`, or `next_steps` are never setter values. A completed ambiguous classification uses the exact hook-accepted marker `❔ ThreadBear could not classify`, never an invented compact unknown footer. Every title wave awaits all native results without a client-created `Promise.race` or other synthetic timeout; the trusted Pre hook intentionally transforms compact setter input into the expanded visible title, so the controller never compares the native return title with the compact input, requires the exact returned task ID, treats an expected hook-expanded title as provisional success, and uses fresh inventory as the authoritative applied result. It reconciles the whole wave through inventory before continuing; every ready stable queue runs in one orchestrated loop with concurrent gates and setters plus fresh inventory after each max-eight wave, without commentary or a model round trip between settled waves. Only an explicit timeout from the native tool is a timeout. A target that explicitly becomes inactive or not found may be dropped only after a fresh authoritative inventory no longer contains its exact ID and every other wave target reconciles as applied; it naturally left the addressable catalog and is not counted as applied. Any other native failure, or an ID that remains present, fails closed. Each worker has an eight-minute deadline, a timed-out or invalid read-only batch gets one bounded retry, and the controller never starts another wave or returns while a retained worker is active or unaccounted for. If zero workers can start for two minutes, or a retry also fails, it records `migration_failed`. Every non-successful exit records `migration_failed` before returning; it never leaves an idle controller described as `migration_running`. An explicit timeout or unknown native title result remains fail-closed until authoritative inventory reconciliation. On success it performs a final inventory with zero remaining rows, then records: + +A bounded `wait_threads` response with `timedOut:true` is normal polling, not a classifier timeout. Continue polling every retained worker until its own eight-minute deadline; only that per-worker deadline can trigger the documented retry or failure path. ```sh ~/.local/bin/threadbear migration \ @@ -199,6 +201,6 @@ Commit refuses unless the prepared owner, clean main title, restored archive sta ## Maintainer verification -A release is ready only after unit and integration tests, the 1,500-line shipped-logic target and 2,000-line absolute gate, isolated install/reinstall/uninstall tests, 0-/1-/200-task controller fixtures, and controller resume, interruption, and failure cases. +A release is ready only after unit and integration tests, the 1,500-line shipped-logic target and 2,000-line absolute gate, isolated install/reinstall/uninstall tests, 0-/1-/200-task controller fixtures, bounded write-wave overlap, and controller resume, interruption, and failure cases. Tests and fixtures alone are not shipping proof. Also execute every lifecycle command printed here against the reviewed release candidate and exercise each changed native lifecycle against a real, recoverable Codex test task through the supported native control. Exercise changed download/update behavior against the official release service in an isolated installation. Record exact candidate SHA, task/release IDs, before/after state, results, and cleanup without visual inspection. Confirm that `INSTALL.md` and `site/install` are byte-identical and that the hosted `threadbear.sh/install` serves the reviewed guide before announcing publication. diff --git a/assets/AGENTS.threadbear.md b/assets/AGENTS.threadbear.md index 0fb12df..b4fb6f5 100644 --- a/assets/AGENTS.threadbear.md +++ b/assets/AGENTS.threadbear.md @@ -20,6 +20,8 @@ text(result); Make exactly one native attempt. The four-second timer is the total wait budget. If it wins, the write result is unknown: end the cell, never retry or await that promise, and continue the turn. Also continue after an explicit returned failure. Do not call the native title tool directly outside this bounded cell. +This bounded current-task shape applies only to the two ordinary title moments above, where `threadId` is omitted. Explicit-target lifecycle mutations are governed by the installed ThreadBear skill instead. Follow that lifecycle's batching and wait rules; do not add this `Promise.race` unless the skill explicitly requires a four-second attempt. + ThreadBear uses that first-call subject only when a fresh task still exposes its raw first message; explicit names, generated short titles, committed subjects, and later user renames remain authoritative. Its hooks expand the compact inputs into the stable visible title. They do not repair stopped turns. If the user stops a turn, the running title remains until the next real turn replaces it. ## Status footer diff --git a/assets/skill/SKILL.md b/assets/skill/SKILL.md index eef247e..0264c8e 100644 --- a/assets/skill/SKILL.md +++ b/assets/skill/SKILL.md @@ -35,7 +35,7 @@ Show a command before running it. Ask for explicit consent before any lifecycle 5. Before any migration, use `codex_app__set_thread_title` to set the initiating task to exactly `🧵🐻 ThreadBear 🐻🧵`, use `codex_app__set_thread_pinned` to pin it, and keep that task selected. 6. Create or update one paused hourly heartbeat through the native automation control. Its ID is `threadbear-maintenance`, name is “ThreadBear maintenance,” target is the persisted main task, and prompt is: “Follow the installed ThreadBear skill's Maintenance section. Reconcile archive work first, process eligible archives serially through native controls, run the verified update check last, and stay quiet when nothing changes.” Reuse only an exact ID/name/kind/target match. A collision stops installation without changing the other automation; never create a cron job or duplicate. If the call fails, report the partial install and stop instead of asking the user to repair it manually. On a reinstall that already reports `migration_complete`, it may be active immediately. 7. For an ordinary guided installation, never use visual inspection, computer control, screenshots, or Codex `/hooks`, and never ask the user to do so. The candidate self-test, installed `self-test --json`, and read-only ThreadBear inventory are the installation gate. Visual hook verification belongs only to **Debug canaries** when the install result explicitly contains `debug_canaries:true`. -8. Create exactly one background migration controller without opening, selecting, or navigating to it, then immediately record it with `migration --phase migration_running`. On a compatible machine with the candidate already downloaded, dispatch it within 60 seconds of consent; do not insert UI inspection, App Server schema generation, native-catalog comparison, or tool-surface discovery before dispatch. If creation fails, leave `migration_pending` unchanged and report that nothing is running plus the exact retry action. +8. Create exactly one projectless background migration controller with `codex_app__create_thread` using `model:"gpt-5.6-terra"` and `thinking:"medium"`, without opening, selecting, or navigating to it, then immediately record it with `migration --phase migration_running`. On a compatible machine with the candidate already downloaded, dispatch it within 60 seconds of consent; do not insert UI inspection, App Server schema generation, native-catalog comparison, or tool-surface discovery before dispatch. If creation fails, leave `migration_pending` unchanged and report that nothing is running plus the exact retry action. 9. Give the controller the **Migration controller** protocol below. Supervise it from the persistent task with compact task waits, reporting only each 25-title milestone or phase change. Do not end the installation turn while durable status is `migration_pending` or `migration_running`. 10. After the controller returns, run `status --json` and `inventory --json`, then verify and activate the exact owned heartbeat with native automation controls. Claim success only at `migration_complete` with zero remaining native-addressable local rows and one active healthy maintenance automation, and repeat that older signed-in ChatGPT chat-history rows were not part of the migration. Leave the heartbeat paused at `migration_pending`, `migration_running`, or `migration_failed`. At `migration_failed`, say migration stopped and is not still working, report applied and remaining counts, name the cause, and give one exact resume action using the same controller ID. @@ -70,13 +70,13 @@ To change the quiet window, pass the requested positive `--archive-after-days N` The controller is the only installation-migration writer and is rerunnable under one persisted controller ID: -1. Run `~/.local/bin/threadbear inventory --json` and use its `status`, `action`, `task_id`, and `applied` fields. The main and controller tasks are already excluded. If any unapplied deterministic rows exist, immediately process the first stable batch of at most 25 through the serial title path before resolving or spawning any Luna worker. Issue the first in-scope title mutation within 60 seconds of controller start and within 15 seconds of the inventory result. -2. Accept exact historical footers deterministically. Split only genuinely ambiguous rows into stable batches of at most 20 tasks. Workers classify and never write titles. When a completed classification remains ambiguous, call the native setter with title exactly `❔ ThreadBear could not classify`; never invent a compact unknown footer. -3. Only after the first deterministic batch has applied, or when no deterministic row exists, classify ambiguous batches with `codex_app__create_thread` using `model:"gpt-5.6-luna"`, `thinking:"medium"`, and a projectless background target. Do not inspect or compare alternative agent surfaces at runtime. Run adaptive waves of fresh, read-only Luna-medium workers, with at most eight workers active. After every successful spawn, immediately record its exact handle and assigned task IDs before attempting another spawn. At the first agent-capacity error, stop launching that wave. Never reinterpret that error as zero workers when earlier spawns succeeded. -4. Account for every retained worker even when results arrive out of order. Wait in bounded snapshots and give each worker eight minutes from spawn. When a worker completes, validate and record its result by handle, then process that batch in stable order through the serial title path while other read-only workers may continue. If a worker reaches its deadline, stop it, discard only that batch's uncommitted classifications, finish accounting for the rest of the wave, and retry the timed-out batch once in the next wave. A second timeout records `migration_failed`. -5. Process one native title write at a time. Convert each row's `status` and `action` to the exact compact ThreadBear footer grammar, using the exact unknown marker from step 2, then call the native setter with the explicit `threadId`. After the first fast batch, continue deterministic rows serially while Luna workers classify; never wait for ambiguous classification before exhausting deterministic progress. The Pre hook immediately re-reads the target and preserves any newer user rename; Post accepts only the exact target/title result and commits the proposal. Re-run inventory after each write or bounded batch and skip only rows reporting `applied:true`. A similar-looking but unowned title still passes through the native Pre/Post boundary. +1. Run `~/.local/bin/threadbear inventory --json` and use its `status`, `action`, `task_id`, and `applied` fields. The main and controller tasks are already excluded. If any unapplied deterministic rows exist, immediately start the first stable batch of at most 25 through the bounded title-wave path. Do not wait for that batch to reconcile before launching classifier workers: start the initial bounded worker-spawn wave concurrently with the first deterministic activation-and-setter wave from the same inventory snapshot. Worker setup must never delay the first in-scope title mutation, which is issued within 60 seconds of controller start and within 15 seconds of the inventory result. +2. Accept exact historical footers deterministically. Split only genuinely ambiguous rows into stable batches of at most 10 tasks. Derive each assigned list mechanically from the parsed inventory `task_id` fields in stable order; never retype, transform, or synthesize an ID, and before spawning assert that every assigned ID is an exact member of that same inventory snapshot. Workers classify and never write titles. Give each worker the exact assigned ID list and require its `phase:"final_answer"` item to contain one JSON array with every assigned ID exactly once and no other ID, followed only by the one terminal ThreadBear status line required by the managed block. Validate only that final-answer item; separate worker commentary is not part of the result grammar. A `wait_threads` snapshot may normalize the one separator newline before the footer into a space, so locate the JSON array's closing bracket and accept one or more whitespace characters followed by the exact required footer; do not require a physical newline. Ignore that required footer only while parsing the array; any other final-answer prose or malformed content invalidates the result. When a completed classification remains ambiguous, call the native setter with title exactly `❔ ThreadBear could not classify`; never invent a compact unknown footer. +3. Classify ambiguous batches with `codex_app__create_thread` using `model:"gpt-5.6-luna"`, `thinking:"low"`, and a projectless background target. Do not inspect or compare alternative agent surfaces at runtime. Launch the mechanically derived initial batches as one bounded concurrent wave of fresh, read-only Luna-low workers, with at most eight workers active, using settled results so one failed spawn cannot hide successful handles. Immediately record every successful handle with its assigned task IDs. Retained classifier worker IDs are installation internals: exclude them from every migration title wave. At an agent-capacity error, account for every earlier success and stop adding to that wave. Never reinterpret that error as zero workers when earlier spawns succeeded. +4. Account for every retained worker even when results arrive out of order. Wait in bounded snapshots and give each worker eight minutes from spawn. A `wait_threads` response with `timedOut:true` is only a normal polling snapshot and is never a worker timeout; continue polling every retained worker until it completes, needs attention, or reaches its own eight-minute deadline. As soon as workers complete, validate each exact assigned ID set and response grammar from step 2, record the results, and archive all currently validated workers together in one concurrent `Promise.all` call through their distinct native explicit targets; never serialize those archives. A missing, mismatched, or unconfirmed archive result fails closed. Immediately process those archived batches in stable order through bounded title waves while other read-only workers continue; never wait for every worker before applying an already validated and archived batch. If a worker reaches its deadline or returns malformed JSON, disallowed prose, a duplicate, an omission, or an unassigned ID, discard only that batch's uncommitted classifications, finish accounting for the rest of the wave, archive the accounted worker, and retry that read-only batch once in the next wave. A second invalid result or actual worker deadline records `migration_failed`. +5. Process native title writes in bounded waves of at most eight distinct targets. Translate each classification to the native `title` exactly as follows: status `complete` requires `title:"🧵🐻 complete"`; status `automation` requires `title:"🧵🐻 automation"`; status `needs_input` requires `title:"🧵🐻 needs input (you): ACTION"`; status `blocked` requires `title:"🧵🐻 blocked (external): ACTION"`; status `next_steps` requires `title:"🧵🐻 next steps (agent): ACTION"`; a completed ambiguous classification requires the exact unknown marker from step 2. The status labels are classification names only and are never setter values: every footer setter value begins with the full literal `🧵🐻` mark, and bare inputs such as `complete`, `blocked`, or `next_steps` are invalid. Never prepend the visible status icon, insert the word `ThreadBear`, include the task subject, or pre-render the visible title; the Pre hook alone expands the compact input around the authoritative subject. Immediately before a title wave, call `codex_app__read_thread` concurrently for every target as a bounded read-only activation gate; require each response's exact task ID and the inventory-planned title, do not navigate or select, and begin the setter wave within 15 seconds. A failed or mismatched activation read is reconciled through fresh inventory under step 7 and is never followed by a blind write. Call the native setter once with that compact `title` and explicit `threadId`; never include the same target twice in a wave. Launch the wave concurrently and await every native result without a client-created `Promise.race` or other synthetic timeout. Only an explicit timeout from the native tool is a timeout. The trusted Pre hook intentionally transforms compact setter input into the expanded visible title, so never compare the native return title with the compact input; require the exact returned task ID, treat an expected hook-expanded title as provisional success, and use fresh inventory as the authoritative applied result. Account for the whole wave before reconciling it with inventory, and do not start another write wave until every target is applied, is definitively inactive under step 7, or the migration has failed closed. Execute every ready stable queue in one orchestrated script loop: for each max-eight wave, concurrently gate, concurrently set, run fresh inventory, and validate before the loop advances; do not add commentary, model deliberation, or a separate outer tool round trip between settled waves. After the first fast batch, continue deterministic waves while Luna workers classify; never wait for ambiguous classification before exhausting deterministic progress. Each Pre hook briefly serializes target read-and-stage work, preserves any newer user rename, and releases before the native calls run; Post accepts only the exact target/title result and commits the per-target proposal. Re-run inventory after every write wave and skip only rows reporting `applied:true`. A similar-looking but unowned title still passes through the native Pre/Post boundary. 6. Do not start another wave or return while a retained worker is still active or unaccounted for. If zero workers can start, wait 30 seconds and retry for at most two minutes; then record `migration_failed`. Do not degrade unclassified rows to unknown merely because worker capacity is temporarily unavailable. -7. A timeout, unknown native result, hook rejection, or unreconcilable target is not blindly retried except for the one bounded read-only classifier retry above. Record `migration_failed` with the same controller ID before every non-successful return and leave the controller visible. Resume only after authoritative inventory establishes whether a prior native write applied. +7. After a write wave, an explicit native timeout, unknown result, hook rejection, or unreconcilable target is not blindly retried except for the one bounded read-only classifier retry above. Finish accounting for the wave. There is one narrow stale-snapshot exception: when the native result explicitly says a target is inactive or not found, immediately refresh authoritative inventory. Continue without that target only when its exact ID is absent from the refreshed inventory and every other target in the wave reconciles as applied; the task naturally left the addressable catalog and is not counted as applied. If that ID remains, the result is ambiguous, or any other failure occurred, fail closed. Record `migration_failed` with the same controller ID before every non-successful return and leave the controller visible. Resume only after authoritative inventory establishes whether a prior native write applied. 8. Report progress after each 25 newly applied rows or phase change. Finish only when a final inventory reports zero remaining rows, then run: ```sh diff --git a/cmd/threadbear/core_test.go b/cmd/threadbear/core_test.go index 7ab797a..0d885f5 100644 --- a/cmd/threadbear/core_test.go +++ b/cmd/threadbear/core_test.go @@ -5,10 +5,12 @@ import ( "context" "database/sql" "encoding/json" + "fmt" "os" "path/filepath" "strings" "testing" + "time" _ "modernc.org/sqlite" ) @@ -205,7 +207,7 @@ func BenchmarkOrdinaryPreToolUse(b *testing.B) { } } -func TestPreToolUseRefusesWhileTitleLifecycleIsLocked(t *testing.T) { +func TestPreToolUseWaitsWhileTitleLifecycleIsLocked(t *testing.T) { root, db := testIndex(t) addTask(t, db, root, "task", "Stable subject", nil, "vscode", 0) if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { @@ -215,15 +217,120 @@ func TestPreToolUseRefusesWhileTitleLifecycleIsLocked(t *testing.T) { if err != nil { t.Fatal(err) } - defer unlock(lock) payload := hookPayload("PreToolUse", "task", "locked", map[string]any{"title": runningMarker + ": Stable subject"}, nil) - var output bytes.Buffer - if err := hook(context.Background(), strings.NewReader(payload), &output); err != nil || !strings.Contains(output.String(), `"permissionDecision":"deny"`) { - t.Fatalf("locked PreToolUse = %q, %v", output.String(), err) + type result struct { + output string + err error + } + done := make(chan result, 1) + go func() { + var output bytes.Buffer + err := hook(context.Background(), strings.NewReader(payload), &output) + done <- result{output: output.String(), err: err} + }() + select { + case got := <-done: + unlock(lock) + t.Fatalf("locked PreToolUse returned early: %q, %v", got.output, got.err) + case <-time.After(25 * time.Millisecond): + } + unlock(lock) + var got result + select { + case got = <-done: + case <-time.After(2 * time.Second): + t.Fatal("PreToolUse did not continue after title lock released") + } + if got.err != nil || !strings.Contains(got.output, `"permissionDecision":"allow"`) { + t.Fatalf("released PreToolUse = %q, %v", got.output, got.err) } saved, err := newStore(stateDir()).read() - if err != nil || saved.Tasks["task"].Pending != nil { - t.Fatalf("locked PreToolUse staged state: %#v, %v", saved.Tasks["task"], err) + if err != nil || saved.Tasks["task"].Pending == nil { + t.Fatalf("released PreToolUse did not stage state: %#v, %v", saved.Tasks["task"], err) + } +} + +func TestConcurrentMigrationTitleWaveCommitsDistinctTargets(t *testing.T) { + root, db := testIndex(t) + const size = 8 + for i := range size { + id := fmt.Sprintf("target-%d", i) + addTask(t, db, root, id, "Subject "+id, nil, "vscode", 0) + } + if err := newStore(stateDir()).update(func(saved *state) (bool, error) { + saved.MainTaskID, saved.ControllerTaskID, saved.Phase = "main", "controller", phaseMigrationRunning + return true, nil + }); err != nil { + t.Fatal(err) + } + lock, err := newStore(stateDir()).titleLock() + if err != nil { + t.Fatal(err) + } + unlock(lock) + type result struct { + id, title, output string + err error + } + prepared := make(chan result, size) + for i := range size { + id := fmt.Sprintf("target-%d", i) + go func() { + var output bytes.Buffer + payload := hookPayload("PreToolUse", "controller", "call-"+id, map[string]any{"threadId": id, "title": "🧵🐻 complete"}, nil) + err := hook(context.Background(), strings.NewReader(payload), &output) + if err != nil { + prepared <- result{id: id, output: output.String(), err: err} + return + } + var value struct { + Hook struct { + Updated map[string]json.RawMessage `json:"updatedInput"` + } `json:"hookSpecificOutput"` + } + err = json.Unmarshal(output.Bytes(), &value) + var title string + if err == nil { + err = json.Unmarshal(value.Hook.Updated["title"], &title) + } + prepared <- result{id: id, title: title, output: output.String(), err: err} + }() + } + posts := make(chan result, size) + items := make([]result, 0, size) + for range size { + items = append(items, <-prepared) + } + for _, item := range items { + if item.err != nil || item.title != "✅ Subject "+item.id { + t.Fatalf("prepared %s = title %q, output %q, %v", item.id, item.title, item.output, item.err) + } + } + for _, item := range items { + go func() { + response, _ := json.Marshal(map[string]string{"threadId": item.id, "title": item.title}) + payload := hookPayload("PostToolUse", "controller", "call-"+item.id, map[string]any{"threadId": item.id, "title": item.title}, string(response)) + posts <- result{id: item.id, title: item.title, err: hook(context.Background(), strings.NewReader(payload), &bytes.Buffer{})} + }() + } + var postFailures []string + for range size { + if item := <-posts; item.err != nil { + postFailures = append(postFailures, fmt.Sprintf("%s: %v", item.id, item.err)) + } + } + if len(postFailures) > 0 { + t.Fatalf("commit failures: %s", strings.Join(postFailures, "; ")) + } + saved, err := newStore(stateDir()).read() + if err != nil { + t.Fatal(err) + } + for i := range size { + id := fmt.Sprintf("target-%d", i) + if got := saved.Tasks[id]; got.Pending != nil || got.Last != "✅ Subject "+id || got.Status != "complete" { + t.Fatalf("target state %s = %#v", id, got) + } } } diff --git a/cmd/threadbear/install.go b/cmd/threadbear/install.go index 768e6e6..2b77af9 100644 --- a/cmd/threadbear/install.go +++ b/cmd/threadbear/install.go @@ -128,10 +128,9 @@ func prepareUninstall(ctx context.Context, initiatorTaskID string) (any, error) } return uninstallPreparationResult(pending, true, reconciled, drifted), nil } - for _, record := range value.Tasks { - if record.Pending != nil { - return nil, errors.New("cannot prepare uninstall while a native title operation is pending; reconcile it first") - } + reconciled, drifted, err := reconcileUninstallTitles(ctx) + if err != nil { + return nil, err } main, found, err := archiveTaskByID(ctx, value.MainTaskID) if err != nil || !found || !main.User { @@ -145,7 +144,7 @@ func prepareUninstall(ctx context.Context, initiatorTaskID string) (any, error) if err != nil { return nil, err } - return uninstallPreparationResult(pending, false, 0, 0), nil + return uninstallPreparationResult(pending, false, reconciled, drifted), nil }) } func uninstallPreparationResult(pending *uninstallOperation, resumed bool, reconciled, drifted int) any { diff --git a/cmd/threadbear/install_test.go b/cmd/threadbear/install_test.go index e630aad..349425f 100644 --- a/cmd/threadbear/install_test.go +++ b/cmd/threadbear/install_test.go @@ -458,6 +458,8 @@ func TestManagedGuidanceBoundsEachNativeTitleCall(t *testing.T) { "new Promise(resolve => setTimeout(() => resolve({status:\"timeout\"}), 4000))", "Make exactly one native attempt.", "never retry or await that promise", + "Explicit-target lifecycle mutations are governed by the installed ThreadBear skill instead.", + "do not add this `Promise.race` unless the skill explicitly requires a four-second attempt", } { if !strings.Contains(guidance, required) { t.Errorf("managed guidance is missing bounded-call contract %q", required) diff --git a/cmd/threadbear/site_contract_test.go b/cmd/threadbear/site_contract_test.go index 199fa25..bbc83aa 100644 --- a/cmd/threadbear/site_contract_test.go +++ b/cmd/threadbear/site_contract_test.go @@ -48,7 +48,7 @@ func TestPublishedInstallGuideMatchesCurrentCLI(t *testing.T) { "Codex limits title length limited to 60 UTF-16 units, so I'll truncate as needed.", "Small local footprint: one binary in ~/.local/bin, a skill, and two hooks.", "One persistent thread, 🧵🐻 ThreadBear 🐻🧵, for changing config and uninstalling.", - "Deterministic classification first; Luna medium only for ambiguity.", + "Deterministic classification and Luna-low ambiguity checks run in parallel.", "A small Luna helper checks in hourly, then stays quiet when there is nothing to do.", "Finished tasks can curl up in the archive after 14 quiet days—and come back whenever you need them.", "ThreadBear keeps itself fresh from verified releases and tells you when it has a new coat.", @@ -65,20 +65,24 @@ func TestPublishedInstallGuideMatchesCurrentCLI(t *testing.T) { "migration_running", "migration_complete", "migration_failed", - "exactly one background migration-controller task", + "exactly one projectless background migration-controller task", "codex_app__set_thread_title", "codex_app__set_thread_pinned", "do not use visual inspection, computer control, screenshots, or Codex `/hooks`", "Older signed-in ChatGPT chat-history rows are outside Codex's current task-title API and will stay unchanged.", "never describe zero local inventory rows as proof that every visible sidebar row changed", "dispatch it within 60 seconds of consent", + "using `model:\"gpt-5.6-terra\"`, `thinking:\"medium\"`", "first title mutation issued within 60 seconds of controller start and within 15 seconds of the inventory result", - "fixed `codex_app__create_thread` surface with Luna medium", + "Worker creation uses the fixed `codex_app__create_thread` surface with `model:\"gpt-5.6-luna\"` and `thinking:\"low\"`", "❔ ThreadBear could not classify", "Do not open, select, or navigate to it.", - "adaptive waves of fresh read-only Luna-medium workers", + "one bounded concurrent spawn wave of fresh read-only Luna-low workers", "Every successful worker handle is recorded and awaited", "results may arrive out of order", + "bounded concurrent waves of at most eight distinct task IDs", + "without a client-created `Promise.race` or other synthetic timeout", + "Only an explicit timeout from the native tool is a timeout", "eight-minute deadline", "active or unaccounted for", "status still says `migration_pending` or `migration_running`", @@ -119,18 +123,58 @@ func TestInstalledSkillDefinesAdaptiveMigrationWaves(t *testing.T) { "never use visual inspection, computer control, screenshots, or Codex `/hooks`", "dispatch it within 60 seconds of consent", "first stable batch of at most 25", + "start the initial bounded worker-spawn wave concurrently with the first deterministic activation-and-setter wave", "within 15 seconds of the inventory result", - "`codex_app__create_thread` using `model:\"gpt-5.6-luna\"`, `thinking:\"medium\"`", + "`codex_app__create_thread` using `model:\"gpt-5.6-luna\"`, `thinking:\"low\"`", "Do not inspect or compare alternative agent surfaces at runtime.", - "stable batches of at most 20 tasks", - "adaptive waves of fresh, read-only Luna-medium workers", - "After every successful spawn, immediately record its exact handle and assigned task IDs", - "At the first agent-capacity error, stop launching that wave.", + "stable batches of at most 10 tasks", + "Derive each assigned list mechanically from the parsed inventory `task_id` fields in stable order", + "never retype, transform, or synthesize an ID", + "one JSON array with every assigned ID exactly once and no other ID", + "Validate only that final-answer item; separate worker commentary is not part of the result grammar.", + "followed only by the one terminal ThreadBear status line required by the managed block", + "A `wait_threads` snapshot may normalize the one separator newline before the footer into a space", + "accept one or more whitespace characters followed by the exact required footer", + "do not require a physical newline", + "Ignore that required footer only while parsing the array", + "one bounded concurrent wave of fresh, read-only Luna-low workers", + "archive all currently validated workers together", + "one concurrent `Promise.all` call", + "never serialize those archives", + "never wait for every worker before applying an already validated and archived batch", + "A `wait_threads` response with `timedOut:true` is only a normal polling snapshot and is never a worker timeout", + "Immediately record every successful handle with its assigned task IDs", + "Retained classifier worker IDs are installation internals: exclude them from every migration title wave.", + "A missing, mismatched, or unconfirmed archive result fails closed", + "At an agent-capacity error, account for every earlier success", "Never reinterpret that error as zero workers when earlier spawns succeeded.", "even when results arrive out of order", "give each worker eight minutes from spawn", "discard only that batch's uncommitted classifications", - "retry the timed-out batch once in the next wave", + "retry that read-only batch once in the next wave", + "A second invalid result or actual worker deadline records `migration_failed`", + "bounded waves of at most eight distinct targets", + "call `codex_app__read_thread` concurrently for every target as a bounded read-only activation gate", + "require each response's exact task ID and the inventory-planned title", + "begin the setter wave within 15 seconds", + "never include the same target twice in a wave", + "without a client-created `Promise.race` or other synthetic timeout", + "Only an explicit timeout from the native tool is a timeout.", + "never compare the native return title with the compact input", + "use fresh inventory as the authoritative applied result", + "Account for the whole wave before reconciling it with inventory", + "Execute every ready stable queue in one orchestrated script loop", + "do not add commentary, model deliberation, or a separate outer tool round trip between settled waves", + "one narrow stale-snapshot exception", + "explicitly says a target is inactive or not found", + "its exact ID is absent from the refreshed inventory", + "the task naturally left the addressable catalog and is not counted as applied", + "status `complete` requires `title:\"🧵🐻 complete\"`", + "status `needs_input` requires `title:\"🧵🐻 needs input (you): ACTION\"`", + "status `next_steps` requires `title:\"🧵🐻 next steps (agent): ACTION\"`", + "bare inputs such as `complete`, `blocked`, or `next_steps` are invalid", + "Never prepend the visible status icon, insert the word `ThreadBear`, include the task subject, or pre-render the visible title", + "the Pre hook alone expands the compact input around the authoritative subject", "Do not start another wave or return while a retained worker is still active or unaccounted for.", "If zero workers can start, wait 30 seconds and retry for at most two minutes", "Record `migration_failed` with the same controller ID before every non-successful return", @@ -146,7 +190,7 @@ func TestInstalledSkillDefinesAdaptiveMigrationWaves(t *testing.T) { t.Error("installed skill still asks end users to inspect Codex hooks") } firstBatch := strings.Index(protocol, "first stable batch of at most 25") - workerSurface := strings.Index(protocol, "`codex_app__create_thread` using") + workerSurface := strings.LastIndex(protocol, "`codex_app__create_thread` using") if firstBatch < 0 || workerSurface < 0 || firstBatch > workerSurface { t.Error("installed skill does not put prompt deterministic progress before Luna worker creation") } diff --git a/cmd/threadbear/state.go b/cmd/threadbear/state.go index 8fd35ac..9d47510 100644 --- a/cmd/threadbear/state.go +++ b/cmd/threadbear/state.go @@ -97,7 +97,7 @@ func (s store) operationLock() (*os.File, error) { return lock, err } func (s store) titleLock() (*os.File, error) { - return s.openLock("title.lock", unix.LOCK_EX|unix.LOCK_NB, false) + return s.openLock("title.lock", unix.LOCK_EX, false) } func unlock(lock *os.File) { _ = unix.Flock(int(lock.Fd()), unix.LOCK_UN) diff --git a/cmd/threadbear/uninstall_protocol_test.go b/cmd/threadbear/uninstall_protocol_test.go index f7902a9..0c1db93 100644 --- a/cmd/threadbear/uninstall_protocol_test.go +++ b/cmd/threadbear/uninstall_protocol_test.go @@ -211,7 +211,7 @@ func TestArchivedControlUninstallAbortRestoresOrdinaryOperation(t *testing.T) { } } -func TestArchivedControlUninstallPrepareRejectsPendingNativeTitle(t *testing.T) { +func TestArchivedControlUninstallPrepareReconcilesPendingNativeTitle(t *testing.T) { root, db := testIndex(t) addTask(t, db, root, "main", "Control task", nil, "vscode", 1) addUninstallOwner(t, db, root) @@ -226,9 +226,13 @@ func TestArchivedControlUninstallPrepareRejectsPendingNativeTitle(t *testing.T) t.Fatal(err) } var output bytes.Buffer - if code := run(context.Background(), []string{"uninstall", "--prepare", "--initiator-task-id", "requester", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 1 || !strings.Contains(output.String(), "native title operation is pending") { + if code := run(context.Background(), []string{"uninstall", "--prepare", "--initiator-task-id", "requester", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 0 || !strings.Contains(output.String(), `"drifted_titles":1`) { t.Fatalf("pending title prepare code %d: %s", code, output.String()) } + value, _ := newStore(stateDir()).read() + if value.Tasks["main"].Pending != nil { + t.Fatal("prepare left reconciled title pending") + } } func TestRetainedCandidateFinishesBinaryRemovalAfterStateCommit(t *testing.T) { diff --git a/site/install b/site/install index 60915ab..da75782 100644 --- a/site/install +++ b/site/install @@ -69,7 +69,7 @@ Only after every check and the dry run succeeds, compose one terminal final answ > - Codex limits title length limited to 60 UTF-16 units, so I'll truncate as needed. > - Small local footprint: one binary in ~/.local/bin, a skill, and two hooks. > - One persistent thread, 🧵🐻 ThreadBear 🐻🧵, for changing config and uninstalling. -> - Deterministic classification first; Luna medium only for ambiguity. +> - Deterministic classification and Luna-low ambiguity checks run in parallel. > - A small Luna helper checks in hourly, then stays quiet when there is nothing to do. > - Finished tasks can curl up in the archive after 14 quiet days—and come back whenever you need them. > - ThreadBear keeps itself fresh from verified releases and tells you when it has a new coat. @@ -123,7 +123,7 @@ Before migration, tell the user: Before any bulk work, use `codex_app__set_thread_title` to set the initiating task to exactly `🧵🐻 ThreadBear 🐻🧵`, use `codex_app__set_thread_pinned` to pin it, and keep this task selected. For an ordinary guided installation, do not use visual inspection, computer control, screenshots, or Codex `/hooks`, and do not ask the user to do so. The candidate self-test, installed `self-test --json`, and read-only ThreadBear inventory are the installation gate; visual hook verification is outside this ordinary installation flow. -Create exactly one background migration-controller task with a prompt containing the controller protocol from the installed ThreadBear skill. Do not open, select, or navigate to it. On a compatible machine with the candidate already downloaded, dispatch it within 60 seconds of consent; do not insert UI inspection, App Server schema generation, native-catalog comparison, or tool-surface discovery before dispatch. If creation fails, leave the truthful `migration_pending` phase and report that nothing is running plus the exact retry action. After successful creation, immediately persist its exact ID: +Create exactly one projectless background migration-controller task with `codex_app__create_thread` using `model:"gpt-5.6-terra"`, `thinking:"medium"`, and a prompt containing the controller protocol from the installed ThreadBear skill. Do not open, select, or navigate to it. On a compatible machine with the candidate already downloaded, dispatch it within 60 seconds of consent; do not insert UI inspection, App Server schema generation, native-catalog comparison, or tool-surface discovery before dispatch. If creation fails, leave the truthful `migration_pending` phase and report that nothing is running plus the exact retry action. After successful creation, immediately persist its exact ID: ```sh ~/.local/bin/threadbear migration \ @@ -131,7 +131,9 @@ Create exactly one background migration-controller task with a prompt containing --controller-task-id "$CONTROLLER_TASK_ID" --json ``` -The controller is the only migration writer. It processes one explicit target at a time and skips only rows already reporting `applied:true`. When deterministic rows exist, it begins a stable batch of at most 25 before discovering or spawning Luna workers, with the first title mutation issued within 60 seconds of controller start and within 15 seconds of the inventory result. It then uses the fixed `codex_app__create_thread` surface with Luna medium to launch adaptive waves of fresh read-only Luna-medium workers for genuinely ambiguous classifications while deterministic writes continue serially. Every successful worker handle is recorded and awaited even if a later spawn hits the agent-capacity limit; results may arrive out of order, but title writes remain serial. A completed ambiguous classification uses the exact hook-accepted marker `❔ ThreadBear could not classify`, never an invented compact unknown footer. Each worker has an eight-minute deadline, a timed-out read-only batch gets one bounded retry, and the controller never starts another wave or returns while a retained worker is active or unaccounted for. If zero workers can start for two minutes, or a retry also times out, it records `migration_failed`. Every non-successful exit records `migration_failed` before returning; it never leaves an idle controller described as `migration_running`. A timeout or unknown native title result remains fail-closed until authoritative inventory reconciliation. On success it performs a final inventory with zero remaining rows, then records: +The controller is the only migration writer. It processes explicit targets in bounded concurrent waves of at most eight distinct task IDs and skips only rows already reporting `applied:true`. Immediately before each title wave it concurrently calls `codex_app__read_thread` for every target as a read-only activation gate, requires each exact task ID and inventory-planned title, and starts the setter wave within 15 seconds without navigating or selecting; a failed or mismatched read is reconciled through fresh inventory and never followed by a blind write. From the first inventory snapshot, it starts the first stable deterministic batch of at most 25 and one bounded concurrent spawn wave of fresh read-only Luna-low workers in stable batches of at most 10 genuinely ambiguous tasks at the same time, with the first title mutation issued within 60 seconds of controller start and within 15 seconds of the inventory result. Worker creation uses the fixed `codex_app__create_thread` surface with `model:"gpt-5.6-luna"` and `thinking:"low"`, records every settled successful handle, and never lets a failed spawn hide an earlier success. The controller derives every assigned list mechanically from the parsed inventory `task_id` fields, never retypes or synthesizes an ID, and asserts exact membership in the same inventory snapshot before spawning. Every successful worker handle is recorded and awaited even if a later spawn hits the agent-capacity limit; retained classifier IDs are excluded from title waves, and all currently validated workers are archived together in one concurrent native explicit-target call before their batches are applied. A missing, mismatched, or unconfirmed worker archive fails closed. Classifier results may arrive out of order. The classifier's `phase:"final_answer"` item must contain one JSON array with every assigned ID exactly once and no other ID, followed only by the one terminal ThreadBear status line required by the managed block; separate worker commentary is not part of the result grammar. Because a `wait_threads` snapshot may normalize the one separator newline into a space, the controller locates the array's closing bracket and accepts one or more whitespace characters before the exact footer instead of requiring a physical newline; it still rejects any other prose. The controller ignores the footer only while parsing the array. Any other final-answer prose, malformed content, or mismatched batch is discarded and retried once without writing titles. The controller passes the compact footer grammar—not a subject, visible status icon, or pre-rendered title—so the Pre hook alone expands the authoritative subject. Every footer setter value begins with the full literal `🧵🐻` mark (`title:"🧵🐻 complete"`, for example); bare classification labels such as `complete`, `blocked`, or `next_steps` are never setter values. A completed ambiguous classification uses the exact hook-accepted marker `❔ ThreadBear could not classify`, never an invented compact unknown footer. Every title wave awaits all native results without a client-created `Promise.race` or other synthetic timeout; the trusted Pre hook intentionally transforms compact setter input into the expanded visible title, so the controller never compares the native return title with the compact input, requires the exact returned task ID, treats an expected hook-expanded title as provisional success, and uses fresh inventory as the authoritative applied result. It reconciles the whole wave through inventory before continuing; every ready stable queue runs in one orchestrated loop with concurrent gates and setters plus fresh inventory after each max-eight wave, without commentary or a model round trip between settled waves. Only an explicit timeout from the native tool is a timeout. A target that explicitly becomes inactive or not found may be dropped only after a fresh authoritative inventory no longer contains its exact ID and every other wave target reconciles as applied; it naturally left the addressable catalog and is not counted as applied. Any other native failure, or an ID that remains present, fails closed. Each worker has an eight-minute deadline, a timed-out or invalid read-only batch gets one bounded retry, and the controller never starts another wave or returns while a retained worker is active or unaccounted for. If zero workers can start for two minutes, or a retry also fails, it records `migration_failed`. Every non-successful exit records `migration_failed` before returning; it never leaves an idle controller described as `migration_running`. An explicit timeout or unknown native title result remains fail-closed until authoritative inventory reconciliation. On success it performs a final inventory with zero remaining rows, then records: + +A bounded `wait_threads` response with `timedOut:true` is normal polling, not a classifier timeout. Continue polling every retained worker until its own eight-minute deadline; only that per-worker deadline can trigger the documented retry or failure path. ```sh ~/.local/bin/threadbear migration \ @@ -199,6 +201,6 @@ Commit refuses unless the prepared owner, clean main title, restored archive sta ## Maintainer verification -A release is ready only after unit and integration tests, the 1,500-line shipped-logic target and 2,000-line absolute gate, isolated install/reinstall/uninstall tests, 0-/1-/200-task controller fixtures, and controller resume, interruption, and failure cases. +A release is ready only after unit and integration tests, the 1,500-line shipped-logic target and 2,000-line absolute gate, isolated install/reinstall/uninstall tests, 0-/1-/200-task controller fixtures, bounded write-wave overlap, and controller resume, interruption, and failure cases. Tests and fixtures alone are not shipping proof. Also execute every lifecycle command printed here against the reviewed release candidate and exercise each changed native lifecycle against a real, recoverable Codex test task through the supported native control. Exercise changed download/update behavior against the official release service in an isolated installation. Record exact candidate SHA, task/release IDs, before/after state, results, and cleanup without visual inspection. Confirm that `INSTALL.md` and `site/install` are byte-identical and that the hosted `threadbear.sh/install` serves the reviewed guide before announcing publication. From 9f1b737d1d520b92ca656b794d5c781e25a8bf06 Mon Sep 17 00:00:00 2001 From: "Eric A. Litman" Date: Tue, 4 Aug 2026 14:01:03 -0400 Subject: [PATCH 02/11] Fix title lifecycle recovery and replacement fencing --- CHANGELOG.md | 4 + INSTALL.md | 2 + README.md | 4 +- assets/help.txt | 1 + assets/skill/SKILL.md | 4 +- cmd/threadbear/core_test.go | 132 ++++++++++++++++++++-- cmd/threadbear/hook.go | 40 ++++--- cmd/threadbear/install.go | 79 ++++++++----- cmd/threadbear/install_test.go | 79 +++++++++++++ cmd/threadbear/lifecycle_test.go | 119 +++++++++++++++++-- cmd/threadbear/main.go | 3 +- cmd/threadbear/maintenance.go | 4 +- cmd/threadbear/migration.go | 41 ++++--- cmd/threadbear/migration_test.go | 4 +- cmd/threadbear/site_contract_test.go | 7 +- cmd/threadbear/state.go | 21 ++-- cmd/threadbear/uninstall_protocol_test.go | 31 +++-- cmd/threadbear/update.go | 39 ++----- cmd/threadbear/update_test.go | 16 +++ docs/architecture.md | 8 +- docs/compatibility.md | 2 +- docs/experiments/registry.json | 29 +++++ site/install | 2 + 23 files changed, 505 insertions(+), 166 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b78a205..8f296ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,12 @@ ## Unreleased +## v2.2.1 - 2026-08-04 + ### Fixed +- Completed guided title migration in bounded concurrent waves without client-created native-call timeouts, while keeping exact inventory reconciliation and read-only Luna classifiers isolated to temporary installation tasks. +- Prevented delayed/older title callbacks and no-op proposals from racing replacement or uninstall teardown with a locked state-format fence, and made authenticated same-controller migration resume reconcile exact applied or durably settled known-unapplied proposals after Codex's success-only post-hook surface. - Made uninstall resumable from any active native task when the persisted ThreadBear home is archived, with durable initiator ownership, exact archive restoration, archived-main title validation, and binary-last local teardown. ## v2.2.0 - 2026-08-03 diff --git a/INSTALL.md b/INSTALL.md index da75782..ca5ddcc 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -133,6 +133,8 @@ Create exactly one projectless background migration-controller task with `codex_ The controller is the only migration writer. It processes explicit targets in bounded concurrent waves of at most eight distinct task IDs and skips only rows already reporting `applied:true`. Immediately before each title wave it concurrently calls `codex_app__read_thread` for every target as a read-only activation gate, requires each exact task ID and inventory-planned title, and starts the setter wave within 15 seconds without navigating or selecting; a failed or mismatched read is reconciled through fresh inventory and never followed by a blind write. From the first inventory snapshot, it starts the first stable deterministic batch of at most 25 and one bounded concurrent spawn wave of fresh read-only Luna-low workers in stable batches of at most 10 genuinely ambiguous tasks at the same time, with the first title mutation issued within 60 seconds of controller start and within 15 seconds of the inventory result. Worker creation uses the fixed `codex_app__create_thread` surface with `model:"gpt-5.6-luna"` and `thinking:"low"`, records every settled successful handle, and never lets a failed spawn hide an earlier success. The controller derives every assigned list mechanically from the parsed inventory `task_id` fields, never retypes or synthesizes an ID, and asserts exact membership in the same inventory snapshot before spawning. Every successful worker handle is recorded and awaited even if a later spawn hits the agent-capacity limit; retained classifier IDs are excluded from title waves, and all currently validated workers are archived together in one concurrent native explicit-target call before their batches are applied. A missing, mismatched, or unconfirmed worker archive fails closed. Classifier results may arrive out of order. The classifier's `phase:"final_answer"` item must contain one JSON array with every assigned ID exactly once and no other ID, followed only by the one terminal ThreadBear status line required by the managed block; separate worker commentary is not part of the result grammar. Because a `wait_threads` snapshot may normalize the one separator newline into a space, the controller locates the array's closing bracket and accepts one or more whitespace characters before the exact footer instead of requiring a physical newline; it still rejects any other prose. The controller ignores the footer only while parsing the array. Any other final-answer prose, malformed content, or mismatched batch is discarded and retried once without writing titles. The controller passes the compact footer grammar—not a subject, visible status icon, or pre-rendered title—so the Pre hook alone expands the authoritative subject. Every footer setter value begins with the full literal `🧵🐻` mark (`title:"🧵🐻 complete"`, for example); bare classification labels such as `complete`, `blocked`, or `next_steps` are never setter values. A completed ambiguous classification uses the exact hook-accepted marker `❔ ThreadBear could not classify`, never an invented compact unknown footer. Every title wave awaits all native results without a client-created `Promise.race` or other synthetic timeout; the trusted Pre hook intentionally transforms compact setter input into the expanded visible title, so the controller never compares the native return title with the compact input, requires the exact returned task ID, treats an expected hook-expanded title as provisional success, and uses fresh inventory as the authoritative applied result. It reconciles the whole wave through inventory before continuing; every ready stable queue runs in one orchestrated loop with concurrent gates and setters plus fresh inventory after each max-eight wave, without commentary or a model round trip between settled waves. Only an explicit timeout from the native tool is a timeout. A target that explicitly becomes inactive or not found may be dropped only after a fresh authoritative inventory no longer contains its exact ID and every other wave target reconciles as applied; it naturally left the addressable catalog and is not counted as applied. Any other native failure, or an ID that remains present, fails closed. Each worker has an eight-minute deadline, a timed-out or invalid read-only batch gets one bounded retry, and the controller never starts another wave or returns while a retained worker is active or unaccounted for. If zero workers can start for two minutes, or a retry also fails, it records `migration_failed`. Every non-successful exit records `migration_failed` before returning; it never leaves an idle controller described as `migration_running`. An explicit timeout or unknown native title result remains fail-closed until authoritative inventory reconciliation. On success it performs a final inventory with zero remaining rows, then records: +For a failed title wave, add `--settled` to the `migration_failed` command only when every admitted native title Promise since the running transition—including the controller's ordinary current-task call—returned a terminal native response, with no timeout, rejection, interruption, missing result, or unaccounted call. The failed phase denies new title proposals, binding the attestation to the existing pending set. The next running transition from the same controller may then commit an exact proposed live title or clear an exact unchanged/disappeared target. Without that attestation, pending proposals remain fail-closed. + A bounded `wait_threads` response with `timedOut:true` is normal polling, not a classifier timeout. Continue polling every retained worker until its own eight-minute deadline; only that per-worker deadline can trigger the documented retry or failure path. ```sh diff --git a/README.md b/README.md index 311822b..1a624ee 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ The canonical shape is ` [ → ]`. ThreadBear owns only d Open [INSTALL.md](INSTALL.md) in a new Codex task and follow the guided preview, consent, persistent-home setup, and supervised controller migration. -ThreadBear installs a standalone Go binary, one small private state file, managed guidance, two Codex hooks, and one consented hourly Luna heartbeat. The initiating task becomes the persistent `🧵🐻 ThreadBear 🐻🧵` home; one ephemeral controller owns installation migration so that home returns promptly, while the heartbeat later handles quiet housekeeping from that task. +ThreadBear installs a standalone Go binary, one small private state file, managed guidance, two Codex hook entries, and one consented hourly Luna heartbeat. The initiating task becomes the persistent `🧵🐻 ThreadBear 🐻🧵` home; one ephemeral controller owns installation migration so that home returns promptly, while the heartbeat later handles quiet housekeeping from that task. ## Commands @@ -40,6 +40,6 @@ From the persistent ThreadBear task, ask to “strip title icons” or “check ## Boundaries -ThreadBear installs no daemon or LaunchAgent. One explicitly consented hourly Codex heartbeat runs maintenance from the persistent Luna-medium task and stays quiet on no-op runs. The CLI alone selects archive candidates, stages one operation, reconciles ownership, and chooses the exact Darwin asset from the official release manifest. Luna calls supported native controls and communicates typed results; it never edits private UI storage, interprets prose to add targets, or chooses/downloads/checksums a release. Updates refuse while archive work is pending, verify repository URLs, SHA-256, embedded version, candidate self-test, candidate install, and installed status, and never downgrade. ThreadBear adds no token counts, model call, or narration to ordinary turns. Installation uses one serial native-writing controller and adaptive waves of read-only Luna-medium workers only when genuinely ambiguous history cannot be classified deterministically; workers classify and never write titles. +ThreadBear installs no daemon or LaunchAgent. One explicitly consented hourly Codex heartbeat runs maintenance from the persistent Luna-medium task and stays quiet on no-op runs. The CLI alone selects archive candidates, stages one operation, reconciles ownership, and chooses the exact Darwin asset from the official release manifest. Luna calls supported native controls and communicates typed results; it never edits private UI storage, interprets prose to add targets, or chooses/downloads/checksums a release. Updates refuse while archive work is pending, verify repository URLs, SHA-256, embedded version, candidate self-test, candidate install, and installed status, and never downgrade. ThreadBear adds no token counts, model call, or narration to ordinary turns. Installation uses one Terra-medium controller, bounded concurrent native-title waves, and bounded read-only Luna-low classifier waves only when genuinely ambiguous history cannot be classified deterministically; workers classify and never write titles. See [architecture](docs/architecture.md), [compatibility](docs/compatibility.md), and the [status footer convention](docs/status-convention.md). diff --git a/assets/help.txt b/assets/help.txt index 60ccc89..241df2b 100644 --- a/assets/help.txt +++ b/assets/help.txt @@ -21,6 +21,7 @@ Install flags: Migration flags: --phase PHASE migration_running, migration_complete, or migration_failed --controller-task-id ID + --settled Confirm a failed wave has no unknown native calls Maintenance flags: --archive TASK_ID Stage or reconcile one eligible archive diff --git a/assets/skill/SKILL.md b/assets/skill/SKILL.md index 0264c8e..45b3562 100644 --- a/assets/skill/SKILL.md +++ b/assets/skill/SKILL.md @@ -76,7 +76,7 @@ The controller is the only installation-migration writer and is rerunnable under 4. Account for every retained worker even when results arrive out of order. Wait in bounded snapshots and give each worker eight minutes from spawn. A `wait_threads` response with `timedOut:true` is only a normal polling snapshot and is never a worker timeout; continue polling every retained worker until it completes, needs attention, or reaches its own eight-minute deadline. As soon as workers complete, validate each exact assigned ID set and response grammar from step 2, record the results, and archive all currently validated workers together in one concurrent `Promise.all` call through their distinct native explicit targets; never serialize those archives. A missing, mismatched, or unconfirmed archive result fails closed. Immediately process those archived batches in stable order through bounded title waves while other read-only workers continue; never wait for every worker before applying an already validated and archived batch. If a worker reaches its deadline or returns malformed JSON, disallowed prose, a duplicate, an omission, or an unassigned ID, discard only that batch's uncommitted classifications, finish accounting for the rest of the wave, archive the accounted worker, and retry that read-only batch once in the next wave. A second invalid result or actual worker deadline records `migration_failed`. 5. Process native title writes in bounded waves of at most eight distinct targets. Translate each classification to the native `title` exactly as follows: status `complete` requires `title:"🧵🐻 complete"`; status `automation` requires `title:"🧵🐻 automation"`; status `needs_input` requires `title:"🧵🐻 needs input (you): ACTION"`; status `blocked` requires `title:"🧵🐻 blocked (external): ACTION"`; status `next_steps` requires `title:"🧵🐻 next steps (agent): ACTION"`; a completed ambiguous classification requires the exact unknown marker from step 2. The status labels are classification names only and are never setter values: every footer setter value begins with the full literal `🧵🐻` mark, and bare inputs such as `complete`, `blocked`, or `next_steps` are invalid. Never prepend the visible status icon, insert the word `ThreadBear`, include the task subject, or pre-render the visible title; the Pre hook alone expands the compact input around the authoritative subject. Immediately before a title wave, call `codex_app__read_thread` concurrently for every target as a bounded read-only activation gate; require each response's exact task ID and the inventory-planned title, do not navigate or select, and begin the setter wave within 15 seconds. A failed or mismatched activation read is reconciled through fresh inventory under step 7 and is never followed by a blind write. Call the native setter once with that compact `title` and explicit `threadId`; never include the same target twice in a wave. Launch the wave concurrently and await every native result without a client-created `Promise.race` or other synthetic timeout. Only an explicit timeout from the native tool is a timeout. The trusted Pre hook intentionally transforms compact setter input into the expanded visible title, so never compare the native return title with the compact input; require the exact returned task ID, treat an expected hook-expanded title as provisional success, and use fresh inventory as the authoritative applied result. Account for the whole wave before reconciling it with inventory, and do not start another write wave until every target is applied, is definitively inactive under step 7, or the migration has failed closed. Execute every ready stable queue in one orchestrated script loop: for each max-eight wave, concurrently gate, concurrently set, run fresh inventory, and validate before the loop advances; do not add commentary, model deliberation, or a separate outer tool round trip between settled waves. After the first fast batch, continue deterministic waves while Luna workers classify; never wait for ambiguous classification before exhausting deterministic progress. Each Pre hook briefly serializes target read-and-stage work, preserves any newer user rename, and releases before the native calls run; Post accepts only the exact target/title result and commits the per-target proposal. Re-run inventory after every write wave and skip only rows reporting `applied:true`. A similar-looking but unowned title still passes through the native Pre/Post boundary. 6. Do not start another wave or return while a retained worker is still active or unaccounted for. If zero workers can start, wait 30 seconds and retry for at most two minutes; then record `migration_failed`. Do not degrade unclassified rows to unknown merely because worker capacity is temporarily unavailable. -7. After a write wave, an explicit native timeout, unknown result, hook rejection, or unreconcilable target is not blindly retried except for the one bounded read-only classifier retry above. Finish accounting for the wave. There is one narrow stale-snapshot exception: when the native result explicitly says a target is inactive or not found, immediately refresh authoritative inventory. Continue without that target only when its exact ID is absent from the refreshed inventory and every other target in the wave reconciles as applied; the task naturally left the addressable catalog and is not counted as applied. If that ID remains, the result is ambiguous, or any other failure occurred, fail closed. Record `migration_failed` with the same controller ID before every non-successful return and leave the controller visible. Resume only after authoritative inventory establishes whether a prior native write applied. +7. After a write wave, an explicit native timeout, unknown result, hook rejection, or unreconcilable target is not blindly retried except for the one bounded read-only classifier retry above. Finish accounting for the wave. There is one narrow stale-snapshot exception: when the native result explicitly says a target is inactive or not found, immediately refresh authoritative inventory. Continue without that target only when its exact ID is absent from the refreshed inventory and every other target in the wave reconciles as applied; the task naturally left the addressable catalog and is not counted as applied. If that ID remains, the result is ambiguous, or any other failure occurred, fail closed. Before every non-successful return, record `migration_failed` with the same controller ID and leave the controller visible. Add `--settled` only when every admitted native title Promise since the running transition—including this controller's ordinary current-task call—returned a terminal native response, with no timeout, Promise rejection, interruption, missing result, or unaccounted call. Never add it merely because readback still equals the prior title. The failed phase denies every new title proposal, binding the attestation to the complete existing pending set. On the next running transition from that same controller task, ThreadBear holds the title lock, commits an exact non-noop proposed title, clears an exact unchanged or disappeared target only after that durable settled attestation, and refuses every other drift. Without `--settled`, all unapplied proposals remain pending for manual fail-closed recovery. 8. Report progress after each 25 newly applied rows or phase change. Finish only when a final inventory reports zero remaining rows, then run: ```sh @@ -124,7 +124,7 @@ You can uninstall from any active native Codex task—even when the ThreadBear h 3. Pause the exact owned heartbeat so it cannot race cleanup. Run `~/.local/bin/threadbear uninstall --prepare --initiator-task-id INITIATOR_ID --json`. Require the returned IDs to match the current task, persisted main task, and distinct controller snapshot. Re-running this command from the same initiating task resumes the same operation; a different task must stop. 4. If the returned original main state is archived, call `codex_app__set_thread_archived` once with that exact `threadId` and `archived:false`, then verify the task through `codex_app__read_thread` without opening it or sending it a turn. The prepared uninstall task remains the only writer. 5. Follow **Title cleanup** serially for every decorated active regular task and an active distinct controller. Re-read and clean `main_task_id` last through one explicit-target native call from the prepared uninstall task. Skip only a target whose authoritative title is already clean. Every mutation gets one four-second attempt, exact readback, and no blind retry after an unknown result. -6. Require the persisted main title to be clean. Restore its exact original archive state with one `codex_app__set_thread_archived` call and verify it through `codex_app__read_thread` before continuing. On a recoverable interruption, leave the automation and local artifacts intact; the same task resumes through step 3 without repeating settled mutations. If uninstall is abandoned, restore and verify the original archive state, run `~/.local/bin/threadbear uninstall --abort --initiator-task-id INITIATOR_ID --json`, verify `aborted:true`, and leave ThreadBear installed. Drifted or missing title targets are reported in `drifted_titles` and left user-owned instead of being rewritten. +6. Require the persisted main title to be clean. Restore its exact original archive state with one `codex_app__set_thread_archived` call and verify it through `codex_app__read_thread` before continuing. On a recoverable interruption, leave the automation and local artifacts intact; the same task resumes through step 3 without repeating settled mutations. If uninstall is abandoned, restore and verify the original archive state, run `~/.local/bin/threadbear uninstall --abort --initiator-task-id INITIATOR_ID --json`, verify `aborted:true`, and leave ThreadBear installed. A missing, drifted, or no-op pending title remains fail-closed until its exact supported settlement; never clear or retry it from a single title snapshot. 7. Delete the exact owned heartbeat and verify it is absent. This is the commit boundary: make no later native task or automation mutation. Run `~/.local/bin/threadbear uninstall --initiator-task-id INITIATOR_ID --noninteractive --confirm --json`. It revalidates the clean main title and restored archive state, tolerates already-removed owned local entries, preserves unrelated config order, and removes the installed binary last. 8. From successful prepare through commit, make no running-title or terminal-title call and append no ThreadBear footer. Ask the user to restart Codex so already-open sessions cannot keep using snapshotted guidance, and report whether the former home ended archived or active. diff --git a/cmd/threadbear/core_test.go b/cmd/threadbear/core_test.go index 0d885f5..2da8a91 100644 --- a/cmd/threadbear/core_test.go +++ b/cmd/threadbear/core_test.go @@ -5,6 +5,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -115,7 +116,7 @@ func TestRolloutFooterUsesLatestExactTerminalMessage(t *testing.T) { } } -func TestOrdinaryHooksRewriteVerifyAndRecoverLostPost(t *testing.T) { +func TestOrdinaryHooksRewriteVerifyAndBlockLostPost(t *testing.T) { root, db := testIndex(t) addTask(t, db, root, "task", "Stable subject", nil, "vscode", 0) if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { @@ -144,7 +145,7 @@ func TestOrdinaryHooksRewriteVerifyAndRecoverLostPost(t *testing.T) { t.Fatalf("committed state = %#v", saved.Tasks["task"]) } - // A setter success with a lost Post remains provisional ownership on the next turn. + // A setter success with a lost Post remains the sole admitted proposal. pre = hookPayload("PreToolUse", "task", "call-2", map[string]any{"title": "🧵🐻 next steps (agent): finish the tests"}, nil) output.Reset() if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { @@ -156,11 +157,8 @@ func TestOrdinaryHooksRewriteVerifyAndRecoverLostPost(t *testing.T) { } pre = hookPayload("PreToolUse", "task", "call-3", map[string]any{"title": runningMarker + ": Changed model seed"}, nil) output.Reset() - if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { - t.Fatal(err) - } - if got := rewrittenTitle(t, output.Bytes()); got != "⏳ Stable subject" { - t.Fatalf("lost-Post recovery duplicated ownership: %q", got) + if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil || !strings.Contains(output.String(), `"permissionDecision":"deny"`) { + t.Fatalf("lost-Post proposal was not kept fail-closed: %q, %v", output.String(), err) } } @@ -250,6 +248,113 @@ func TestPreToolUseWaitsWhileTitleLifecycleIsLocked(t *testing.T) { } } +func TestPreToolUseQueuedBehindTeardownCannotRecreateState(t *testing.T) { + root, db := testIndex(t) + addTask(t, db, root, "task", "Stable subject", nil, "vscode", 0) + if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { + t.Fatal(err) + } + lock, err := newStore(stateDir()).titleLock() + if err != nil { + t.Fatal(err) + } + payload := hookPayload("PreToolUse", "task", "teardown", map[string]any{"title": runningMarker + ": Stable subject"}, nil) + done := make(chan string, 1) + go func() { + var output bytes.Buffer + _ = hook(context.Background(), strings.NewReader(payload), &output) + done <- output.String() + }() + select { + case output := <-done: + unlock(lock) + t.Fatalf("queued PreToolUse returned before teardown: %q", output) + case <-time.After(25 * time.Millisecond): + } + if err := os.RemoveAll(stateDir()); err != nil { + unlock(lock) + t.Fatal(err) + } + unlock(lock) + select { + case output := <-done: + if !strings.Contains(output, `"permissionDecision":"deny"`) { + t.Fatalf("post-teardown PreToolUse = %q", output) + } + case <-time.After(2 * time.Second): + t.Fatal("post-teardown PreToolUse did not return") + } + if _, err := os.Stat(stateDir()); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("queued PreToolUse recreated state: %v", err) + } +} + +func TestPostToolUseQueuedBehindTeardownCannotRecreateState(t *testing.T) { + root, db := testIndex(t) + addTask(t, db, root, "task", "Stable subject", nil, "vscode", 0) + if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { + t.Fatal(err) + } + var output bytes.Buffer + pre := hookPayload("PreToolUse", "task", "delayed-post", map[string]any{"title": runningMarker + ": Stable subject"}, nil) + if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { + t.Fatal(err) + } + proposed := rewrittenTitle(t, output.Bytes()) + response, _ := json.Marshal(map[string]string{"threadId": "task", "title": proposed}) + post := hookPayload("PostToolUse", "task", "delayed-post", map[string]any{"title": proposed}, string(response)) + lock, err := newStore(stateDir()).titleLock() + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- hook(context.Background(), strings.NewReader(post), &bytes.Buffer{}) }() + select { + case err := <-done: + unlock(lock) + t.Fatalf("queued PostToolUse returned before teardown: %v", err) + case <-time.After(25 * time.Millisecond): + } + if err := os.RemoveAll(stateDir()); err != nil { + unlock(lock) + t.Fatal(err) + } + unlock(lock) + select { + case err := <-done: + if err == nil { + t.Fatal("post-teardown PostToolUse unexpectedly succeeded") + } + case <-time.After(2 * time.Second): + t.Fatal("post-teardown PostToolUse did not return") + } + if _, err := os.Stat(stateDir()); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("queued PostToolUse recreated state: %v", err) + } +} + +func TestDeniedSecondExplicitCallCannotClearFirstProposal(t *testing.T) { + root, db := testIndex(t) + addTask(t, db, root, "target", "Stable subject", nil, "vscode", 0) + if err := newStore(stateDir()).update(func(*state) (bool, error) { return false, nil }); err != nil { + t.Fatal(err) + } + var output bytes.Buffer + pre := hookPayload("PreToolUse", "controller", "failed-call", map[string]any{"threadId": "target", "title": "🧵🐻 complete"}, nil) + if err := hook(context.Background(), strings.NewReader(pre), &output); err != nil { + t.Fatal(err) + } + output.Reset() + second := hookPayload("PreToolUse", "controller", "denied-call", map[string]any{"threadId": "target", "title": "🧵🐻 next steps (agent): retry"}, nil) + if err := hook(context.Background(), strings.NewReader(second), &output); err != nil || !strings.Contains(output.String(), `"permissionDecision":"deny"`) { + t.Fatalf("second proposal = %q, %v", output.String(), err) + } + saved, err := newStore(stateDir()).read() + if pending := saved.Tasks["target"].Pending; err != nil || pending == nil || pending.ToolUseID != "failed-call" || pending.CallerTaskID != "controller" { + t.Fatalf("denied second call changed the first proposal: %#v, %v", saved.Tasks["target"], err) + } +} + func TestConcurrentMigrationTitleWaveCommitsDistinctTargets(t *testing.T) { root, db := testIndex(t) const size = 8 @@ -491,7 +596,7 @@ func TestRestartFirstMessageProjectionPreservesOwnership(t *testing.T) { if err := newStore(stateDir()).update(func(saved *state) (bool, error) { saved.Tasks["raw"] = taskState{Subject: "Committed owner", Last: "✅ Committed owner", Status: "complete"} saved.Tasks["truncated"] = taskState{Subject: "Committed owner", Last: "✅ Committed owner", Status: "complete"} - saved.Tasks["pending"] = taskState{Pending: &pendingProposal{BaseSubject: "Pending owner"}} + saved.Tasks["pending"] = taskState{Pending: &pendingProposal{BaseSubject: "Pending owner", Proposed: first}} return true, nil }); err != nil { t.Fatal(err) @@ -499,7 +604,6 @@ func TestRestartFirstMessageProjectionPreservesOwnership(t *testing.T) { for id, call := range map[string][2]string{ "raw": {runningMarker + ": Replacement seed", "⏳ Committed owner"}, "truncated": {"🧵🐻 complete", "✅ Committed owner"}, - "pending": {runningMarker + ": Replacement seed", "⏳ Pending owner"}, "renamed": {runningMarker + ": Replacement seed", "⏳ Manual user rename"}, "named": {runningMarker + ": Replacement seed", "⏳ Explicit name"}, } { @@ -509,6 +613,11 @@ func TestRestartFirstMessageProjectionPreservesOwnership(t *testing.T) { t.Fatalf("%s restart rewrite = %q, %v", id, output.String(), err) } } + var pendingOutput bytes.Buffer + pendingPre := hookPayload("PreToolUse", "pending", "call-pending", map[string]any{"title": runningMarker + ": Replacement seed"}, nil) + if err := hook(context.Background(), strings.NewReader(pendingPre), &pendingOutput); err != nil || !strings.Contains(pendingOutput.String(), `"permissionDecision":"deny"`) { + t.Fatalf("unsettled proposal was not kept fail-closed: %q, %v", pendingOutput.String(), err) + } for _, id := range []string{"fresh", "delegated"} { var output bytes.Buffer pre := hookPayload("PreToolUse", id, "terminal-"+id, map[string]any{"title": "🧵🐻 complete"}, nil) @@ -594,14 +703,13 @@ func TestControlTaskCleanupStagesAndCommitsStrippedSubject(t *testing.T) { } } -func TestControlTaskCleanupHandlesPendingIconOnlyAndLiteralEmoji(t *testing.T) { +func TestControlTaskCleanupHandlesIconOnlyAndLiteralEmoji(t *testing.T) { root, db := testIndex(t) for id, title := range map[string]string{"icons": "❔ ❔ ❔", "emoji": "🎉 ✅ user title", "main": "✅ ✅ ThreadBear"} { addTask(t, db, root, id, title, nil, "vscode", 0) } if err := newStore(stateDir()).update(func(saved *state) (bool, error) { saved.MainTaskID = "main" - saved.Tasks["icons"] = taskState{Pending: &pendingProposal{ToolUseID: "stale", Proposed: "❔ stale"}} return true, nil }); err != nil { t.Fatal(err) @@ -676,7 +784,7 @@ func TestBulkMarkerRereadsExplicitTargetAndAdoptsRename(t *testing.T) { t.Fatalf("bulk title = %q", desired) } saved, _ := newStore(stateDir()).read() - if saved.Tasks["target"].Pending.ToolUseID != "bulk-1" { + if pending := saved.Tasks["target"].Pending; pending.ToolUseID != "bulk-1" || pending.CallerTaskID != "installer" { t.Fatal("bulk proposal was not bound to native call") } response, _ := json.Marshal(map[string]string{"threadId": "target", "title": desired}) diff --git a/cmd/threadbear/hook.go b/cmd/threadbear/hook.go index 71870c3..0131ccf 100644 --- a/cmd/threadbear/hook.go +++ b/cmd/threadbear/hook.go @@ -43,10 +43,7 @@ func stringField(values map[string]json.RawMessage, key string, required bool) ( func titleTarget(event hookInput) (string, string, error) { title, titleErr := stringField(event.ToolInput, "title", true) target, targetErr := stringField(event.ToolInput, "threadId", false) - if target == "" { - target = event.SessionID - } - return title, target, errors.Join(titleErr, targetErr) + return title, cmp.Or(target, event.SessionID), errors.Join(titleErr, targetErr) } func hook(ctx context.Context, in io.Reader, out io.Writer) error { var event hookInput @@ -56,9 +53,21 @@ func hook(ctx context.Context, in io.Reader, out io.Writer) error { if event.ToolName != titleTool { return nil } + store := newStore(stateDir()) + titleLock, err := store.titleLock() + if err != nil { + return err + } + defer unlock(titleLock) + if _, err = store.read(); err != nil && event.Event != "PreToolUse" { + return err + } switch event.Event { case "PreToolUse": - if err := preTitle(ctx, event, out); err != nil { + if err == nil { + err = preTitle(ctx, event, out) + } + if err != nil { return json.NewEncoder(out).Encode(map[string]any{"hookSpecificOutput": map[string]any{ "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "ThreadBear could not safely prepare this title: " + err.Error(), @@ -105,13 +114,7 @@ func preTitle(ctx context.Context, event hookInput, out io.Writer) error { }}) } func stageTitle(ctx context.Context, id, status, action, seed, caller, toolUseID string) (string, error) { - store := newStore(stateDir()) - titleLock, err := store.titleLock() - if err != nil { - return "", err - } - defer unlock(titleLock) - task, found := indexedTask{Title: seed, Name: seed}, true + task, found, err := indexedTask{Title: seed, Name: seed}, true, error(nil) if status != "" { task, found, err = oneTask(ctx, id) } @@ -119,11 +122,14 @@ func stageTitle(ctx context.Context, id, status, action, seed, caller, toolUseID return "", errors.Join(err, errors.New("task is not active in Codex")) } var proposed string - err = store.update(func(saved *state) (bool, error) { - if pending := saved.UninstallPending; pending != nil && (pending.InitiatorTaskID != caller || status != "cleanup") { - return false, errors.New("title changes are paused for the prepared uninstall task") + err = newStore(stateDir()).update(func(saved *state) (bool, error) { + if pending := saved.UninstallPending; saved.Phase == phaseMigrationFailed || pending != nil && (pending.InitiatorTaskID != caller || status != "cleanup") { + return false, errors.New("title changes are paused for failed migration or prepared uninstall") } record := saved.Tasks[id] + if record.Pending != nil { + return false, errors.New("native title operation is already pending") + } current, first := strings.Join(strings.Fields(task.Title), " "), strings.Join(strings.Fields(task.FirstMessage), " ") subject := canonicalSubject(task.Title, record) if record.Subject == "" && saved.Phase == phaseMigrationRunning && saved.ControllerTaskID == caller && caller != id { @@ -151,7 +157,7 @@ func stageTitle(ctx context.Context, id, status, action, seed, caller, toolUseID } } proposed = map[bool]string{true: seed, false: renderTitle(status, subject, action)}[status == ""] - record.Pending = &pendingProposal{ToolUseID: toolUseID, BaseSubject: subject, Prior: task.Title, Proposed: proposed, Status: status, Action: action} + record.Pending = &pendingProposal{CallerTaskID: caller, ToolUseID: toolUseID, BaseSubject: subject, Prior: task.Title, Proposed: proposed, Status: status, Action: action} saved.Tasks[id] = record return true, nil }) @@ -168,7 +174,7 @@ func postTitle(event hookInput) error { return false, nil } pending := record.Pending - if pending.ToolUseID != event.ToolUseID || pending.Proposed != title { + if pending.ToolUseID != event.ToolUseID || pending.CallerTaskID != "" && pending.CallerTaskID != event.SessionID || pending.Proposed != title { return false, errors.New("native title call does not match its proposal") } var encoded string diff --git a/cmd/threadbear/install.go b/cmd/threadbear/install.go index 2b77af9..8ff91d2 100644 --- a/cmd/threadbear/install.go +++ b/cmd/threadbear/install.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "github.com/ericlitman/threadbear/assets" + "maps" "os" "path/filepath" "runtime" @@ -18,6 +19,9 @@ const blockStart, blockEnd, managedHeading, managedProtocol = "", "", "# ThreadBear", "For every ordinary interactive turn" @@ -265,10 +266,10 @@ func finishCommittedUninstall() (bool, error) { if !errors.Is(skillErr, os.ErrNotExist) || agentsErr == nil && (strings.Contains(string(agents), blockStart) || strings.Contains(string(agents), blockEnd) || strings.Contains(string(agents), managedHeading) || strings.Contains(string(agents), managedProtocol)) || agentsErr != nil && !errors.Is(agentsErr, os.ErrNotExist) || hooksErr != nil || hooksChanged { return false, errors.Join(agentsErr, hooksErr, errors.New("uninstall state is missing before local artifacts were settled")) } - if err := errors.Join(os.RemoveAll(filepath.Dir(p.skill)), os.RemoveAll(stateDir())); err != nil { + if err := errors.Join(removeFiles(filepath.Dir(p.skill), p.skill, filepath.Dir(p.skill)), os.RemoveAll(stateDir())); err != nil { return false, err } - return true, removeFiles(p.binary) + return true, removeFiles("", p.binary) } func uninstallLocked(ctx context.Context, value state) (any, error) { for _, record := range value.Tasks { @@ -302,13 +303,13 @@ func uninstallLocked(ctx context.Context, value state) (any, error) { } } if err == nil { - err = os.RemoveAll(filepath.Dir(p.skill)) + err = removeFiles(filepath.Dir(p.skill), p.skill, filepath.Dir(p.skill)) } if err == nil { err = os.RemoveAll(stateDir()) } if err == nil { - err = removeFiles(p.binary) + err = removeFiles("", p.binary) } return map[string]any{"ready": err == nil, "uninstalled": err == nil}, err } @@ -475,9 +476,12 @@ func writeAtomic(path string, data []byte, mode os.FileMode) error { } return os.Rename(f.Name(), path) } -func removeFiles(paths ...string) error { +func removeFiles(nonEmptyOK string, paths ...string) error { for _, path := range paths { - if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + if info, _ := os.Lstat(path); path == nonEmptyOK && info != nil && !info.IsDir() { + continue + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) && !(path == nonEmptyOK && errors.Is(err, syscall.ENOTEMPTY)) { return err } } diff --git a/cmd/threadbear/install_test.go b/cmd/threadbear/install_test.go index e86d163..3d2d226 100644 --- a/cmd/threadbear/install_test.go +++ b/cmd/threadbear/install_test.go @@ -55,6 +55,8 @@ func TestInstallReinstallAndUninstallPreserveForeignHooks(t *testing.T) { }); err != nil { t.Fatal(err) } + userSkillFile := filepath.Join(filepath.Dir(p.skill), "user-notes.md") + mustWrite(t, userSkillFile, "preserve me") if _, err := uninstall(context.Background(), true); err != nil { t.Fatal(err) } @@ -67,6 +69,9 @@ func TestInstallReinstallAndUninstallPreserveForeignHooks(t *testing.T) { if string(agents) != foreignAgents { t.Fatalf("foreign AGENTS content changed: %q", agents) } + if got, err := os.ReadFile(userSkillFile); err != nil || string(got) != "preserve me" { + t.Fatalf("uninstall changed user skill content: %q, %v", got, err) + } for _, path := range []string{p.binary, p.skill, stateDir()} { if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatalf("uninstall left %s: %v", path, err) @@ -74,6 +79,43 @@ func TestInstallReinstallAndUninstallPreserveForeignHooks(t *testing.T) { } } +func TestUninstallPreservesUserOwnedSkillDirectorySymlink(t *testing.T) { + p := isolatedLifecycle(t) + target := filepath.Join(t.TempDir(), "skill-target") + if err := os.MkdirAll(target, 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(filepath.Dir(p.skill)), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Dir(p.skill)); err != nil { + t.Fatal(err) + } + userFile := filepath.Join(target, "user-notes.md") + mustWrite(t, userFile, "preserve me") + if _, err := install("installer", false, true, false); err != nil { + t.Fatal(err) + } + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.Phase = phaseMigrationComplete + return true, nil + }); err != nil { + t.Fatal(err) + } + if _, err := uninstall(context.Background(), true); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(p.skill); !os.IsNotExist(err) { + t.Fatalf("uninstall left managed skill: %v", err) + } + if info, err := os.Lstat(filepath.Dir(p.skill)); err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("uninstall changed user-owned skill symlink: %#v, %v", info, err) + } + if got, err := os.ReadFile(userFile); err != nil || string(got) != "preserve me" { + t.Fatalf("uninstall changed symlinked user skill content: %q, %v", got, err) + } +} + func TestInstallDryRunAndConfirmationDoNotMutate(t *testing.T) { p := isolatedLifecycle(t) if _, err := install("installer", true, false, false); err != nil { diff --git a/cmd/threadbear/scan.go b/cmd/threadbear/scan.go index 9df92d9..cbb8520 100644 --- a/cmd/threadbear/scan.go +++ b/cmd/threadbear/scan.go @@ -17,7 +17,7 @@ import ( "strings" ) -type indexedTask struct{ ID, Title, RolloutPath, Name, FirstMessage string } +type indexedTask struct{ ID, Title, RolloutPath, Name, FirstMessage, ThreadSource string } func inventory(ctx context.Context) ([]indexedTask, error) { db, err := openIndex() @@ -25,7 +25,7 @@ func inventory(ctx context.Context) ([]indexedTask, error) { return nil, err } defer db.Close() - rows, err := db.QueryContext(ctx, `SELECT id, COALESCE(name,title,''), COALESCE(rollout_path,''), COALESCE(name,''), COALESCE(first_user_message,'') + rows, err := db.QueryContext(ctx, `SELECT id, COALESCE(name,title,''), COALESCE(rollout_path,''), COALESCE(name,''), COALESCE(first_user_message,''), COALESCE(thread_source,'') FROM threads WHERE archived=0 AND preview<>'' AND source IN ('vscode','cli') ORDER BY id`) if err != nil { return nil, fmt.Errorf("read Codex task index: %w", err) @@ -34,7 +34,7 @@ func inventory(ctx context.Context) ([]indexedTask, error) { var tasks []indexedTask for rows.Next() { var task indexedTask - if err := rows.Scan(&task.ID, &task.Title, &task.RolloutPath, &task.Name, &task.FirstMessage); err != nil { + if err := rows.Scan(&task.ID, &task.Title, &task.RolloutPath, &task.Name, &task.FirstMessage, &task.ThreadSource); err != nil { return nil, err } tasks = append(tasks, task) @@ -51,8 +51,8 @@ func oneTask(ctx context.Context, id string) (indexedTask, bool, error) { } defer db.Close() var task indexedTask - err = db.QueryRowContext(ctx, `SELECT id, COALESCE(name,title,''), COALESCE(rollout_path,''), COALESCE(name,''), COALESCE(first_user_message,'') - FROM threads WHERE id=? AND archived=0 AND preview<>'' AND source IN ('vscode','cli')`, id).Scan(&task.ID, &task.Title, &task.RolloutPath, &task.Name, &task.FirstMessage) + err = db.QueryRowContext(ctx, `SELECT id, COALESCE(name,title,''), COALESCE(rollout_path,''), COALESCE(name,''), COALESCE(first_user_message,''), COALESCE(thread_source,'') + FROM threads WHERE id=? AND archived=0 AND preview<>'' AND source IN ('vscode','cli')`, id).Scan(&task.ID, &task.Title, &task.RolloutPath, &task.Name, &task.FirstMessage, &task.ThreadSource) if errors.Is(err, sql.ErrNoRows) { return indexedTask{}, false, nil } diff --git a/cmd/threadbear/uninstall_protocol_test.go b/cmd/threadbear/uninstall_protocol_test.go index b448a51..a0034a5 100644 --- a/cmd/threadbear/uninstall_protocol_test.go +++ b/cmd/threadbear/uninstall_protocol_test.go @@ -289,17 +289,17 @@ func TestRetainedCandidateFinishesBinaryRemovalAfterStateCommit(t *testing.T) { } if write { if len(hooks) == 0 { - if err := removeFiles(p.hooks); err != nil { + if err := removeFiles("", p.hooks); err != nil { t.Fatal(err) } } else if err := writeAtomic(p.hooks, hooks, 0o600); err != nil { t.Fatal(err) } } - if err := removeFiles(p.skill); err != nil { + if err := removeFiles("", p.skill); err != nil { t.Fatal(err) } - if err := removeFiles(newStore(stateDir()).path()); err != nil { + if err := removeFiles("", newStore(stateDir()).path()); err != nil { t.Fatal(err) } if _, err := os.Stat(p.binary); err != nil { diff --git a/cmd/threadbear/update.go b/cmd/threadbear/update.go index 15ba868..03eeb44 100644 --- a/cmd/threadbear/update.go +++ b/cmd/threadbear/update.go @@ -22,11 +22,9 @@ import ( const updateManifestLimit = int64(1 << 20) -var ( - updateReleaseBase, updateManifestURL = "https://github.com/ericlitman/threadbear/releases", "https://github.com/ericlitman/threadbear/releases/latest/download/latest.json" - updateClient, updateBinaryLimit = &http.Client{Timeout: 30 * time.Second}, int64(64 << 20) - updateGOOS, updateGOARCH, updateVersionTimeout, updateCandidateTimeout, updateInstallTimeout = runtime.GOOS, runtime.GOARCH, 30 * time.Second, 30 * time.Second, 2 * time.Minute -) +var updateReleaseBase, updateManifestURL = "https://github.com/ericlitman/threadbear/releases", "https://github.com/ericlitman/threadbear/releases/latest/download/latest.json" +var updateClient, updateBinaryLimit = &http.Client{Timeout: 30 * time.Second}, int64(64 << 20) +var updateGOOS, updateGOARCH, updateVersionTimeout, updateCandidateTimeout, updateInstallTimeout = runtime.GOOS, runtime.GOARCH, 30 * time.Second, 30 * time.Second, 2 * time.Minute type updateError struct { Stage string diff --git a/docs/architecture.md b/docs/architecture.md index f7a8c06..5076f47 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -31,12 +31,12 @@ For a newer or repairing release, the command downloads bounded checksum and bin The visible title is limited to 60 UTF-16 units. Rendering first computes the bounded standalone status-and-subject display, then truncates or omits only the appended action without changing canonical state. -The persisted main task may request one reserved cleanup marker for an explicit target. The same Pre/Post transaction re-reads the target, removes every consecutive leading ThreadBear status mark, stages the subject-only title, validates the exact native result, and repairs ownership state. When the persistent home is still the bare or status-decorated install sentinel, cleanup restores the exact pre-install title captured by confirmed install. During a prepared uninstall, the persisted active initiator receives that cleanup authority and every other title call is denied. The initiator serially cleans active tasks, temporarily unarchives the persisted main only when required, cleans it last, restores its original archive state, and verifies both title and archive settlement before deleting the exact automation. A quiescent pre-controller pending install never owned inventory titles, so that uninstall leaves them untouched and cleans only the persistent home. Local teardown is then a rerunnable commit that validates managed content, removes state before the installed executable, and never performs another native mutation. On-demand cleanup outside uninstall remains restricted to the persisted main task. +The persisted main task may request one reserved cleanup marker for an explicit target. The same Pre/Post transaction re-reads the target, removes every consecutive leading ThreadBear status mark, stages the subject-only title, validates the exact native result, and repairs ownership state. When the persistent home is still the bare or status-decorated install sentinel, cleanup restores the exact pre-install title captured by confirmed install. During a prepared uninstall, the persisted active initiator receives that cleanup authority and every other title call is denied. The initiator serially cleans active tasks, temporarily unarchives the persisted main only when required, cleans it last, restores its original archive state, and verifies both title and archive settlement before deleting the exact automation. A quiescent pre-controller pending install never owned inventory titles, so that uninstall leaves them untouched and cleans only the persistent home. Local teardown is then a rerunnable commit that validates and removes only the managed skill file, leaves any user-created neighbors intact, removes the skill directory only when empty, removes state before the installed executable, and never performs another native mutation. On-demand cleanup outside uninstall remains restricted to the persisted main task. ## Installation and migration The owned heartbeat remains paused until migration is complete, so it cannot race or noisily fail during a long installation. A reinstall already at `migration_complete` may keep the same exact automation active. -Installation writes the binary, state, guidance, skill, and two hook entries while preserving unrelated managed files and hook order, then creates or updates only the exact owned hourly heartbeat through native automation controls. It records `migration_pending` until the marked controller's first host-side current-title hook registers the actual native runtime ID, so an interrupted pre-controller install is never described as running and a provisional creation handle is never treated as authority. Ordinary guided installation trusts deterministic self-test and inventory evidence; Desktop visual inspection and computer control are debug/release-canary tools, never an end-user gate. The initiating task is recorded as the persistent ThreadBear home and stays selected while it creates and supervises exactly one projectless Terra-medium migration controller to a terminal phase. The controller inventories native-addressable unarchived local Codex Desktop and CLI tasks, excludes rollout-only internal records plus the main/controller IDs, and immediately starts the first deterministic title wave while launching the fixed-surface classifier wave. Older signed-in ChatGPT chat-history rows can also render in the Desktop sidebar, but they are outside the pageable native inventory and explicit-target title mutation contract and remain unchanged. The guide discloses that boundary and never equates local zero-remaining evidence with every visible sidebar row. The controller classifies exact footers deterministically and uses bounded read-only Luna-low worker waves only for genuinely ambiguous history, with `❔ ThreadBear could not classify` as the exact hook-accepted unknown proposal. It retains and awaits every successfully spawned worker even when a later spawn reaches collaboration capacity; worker results may complete out of order, while native title calls run in reconciled waves of at most eight distinct targets and deterministic work never waits on ambiguous classification. Each Pre hook briefly serializes its fresh target read and proposal stage, then Post verifies the exact native result; authoritative inventory settles the whole wave before another begins. When an ownerless migration title begins with prior ThreadBear status marks, the controller boundary removes those marks before rendering so reinstall cannot compound decoration. +Installation writes the binary, state, guidance, skill, and two hook entries while preserving unrelated managed files and hook order, then creates or updates only the exact owned hourly heartbeat through native automation controls. It records `migration_pending` until the marked controller's first host-side current-title hook registers the actual native runtime ID from a native-indexed subagent task whose delegation names the exact persistent home, so an interrupted pre-controller install is never described as running, an ordinary user task cannot forge the controller marker, and a provisional creation handle is never treated as authority. Ordinary guided installation trusts deterministic self-test and inventory evidence; Desktop visual inspection and computer control are debug/release-canary tools, never an end-user gate. The initiating task is recorded as the persistent ThreadBear home and stays selected while it creates and supervises exactly one projectless Terra-medium migration controller to a terminal phase. The controller inventories native-addressable unarchived local Codex Desktop and CLI tasks, excludes rollout-only internal records plus the main/controller IDs, and immediately starts the first deterministic title wave while launching the fixed-surface classifier wave. Older signed-in ChatGPT chat-history rows can also render in the Desktop sidebar, but they are outside the pageable native inventory and explicit-target title mutation contract and remain unchanged. The guide discloses that boundary and never equates local zero-remaining evidence with every visible sidebar row. The controller classifies exact footers deterministically and uses bounded read-only Luna-low worker waves only for genuinely ambiguous history, with `❔ ThreadBear could not classify` as the exact hook-accepted unknown proposal. It retains and awaits every successfully spawned worker even when a later spawn reaches collaboration capacity; worker results may complete out of order, while native title calls run in reconciled waves of at most eight distinct targets and deterministic work never waits on ambiguous classification. Each Pre hook briefly serializes its fresh target read and proposal stage, then Post verifies the exact native result; authoritative inventory settles the whole wave before another begins. When an ownerless migration title begins with prior ThreadBear status marks, the controller boundary removes those marks before rendering so reinstall cannot compound decoration. Migration is rerunnable from the registered native runtime controller ID and skips only inventory rows proven `applied: true` from exact committed ownership state. Native writes run only in reconciled waves of at most eight distinct targets. On resume, the shared title lock permits that controller to commit a lost success hook only when the authoritative live title exactly equals its non-noop proposal; a timeout, Promise rejection, interruption, missing result, legacy proposal, foreign proposal, or drift remains fail-closed. The persistent home may attest that a failed wave settled only when complete controller accounting proves every admitted call returned a terminal result; that durable attestation permits only an exact unchanged or disappeared proposal to clear. Reinstall, update, and `migration_complete` refuse every pending title proposal. Only a final zero-remaining inventory with no pending proposal may record completion. The persistent task supervises progress, verifies final inventory, and records the terminal phase without becoming a second title writer. Status repairs an older running-without-controller state to pending and reconciles a missing controller or a terminal lifecycle event from stale `migration_running` to `migration_failed` without using age as a failure signal. A stopped failed migration with no unsettled native operation, or a quiescent pre-controller pending install, can enter the same exact-owner uninstall protocol as a completed migration. Rendered active-header and sidebar verification belongs in opt-in release QA. diff --git a/docs/experiments/registry.json b/docs/experiments/registry.json index 0b2037e..8226e2d 100644 --- a/docs/experiments/registry.json +++ b/docs/experiments/registry.json @@ -60,15 +60,13 @@ { "id": "TB-CAP-BOUNDED-MIGRATION-WAVES", "premise": "One exact candidate can migrate the full native-addressable local task inventory in bounded concurrent title waves within six minutes and uninstall without a queued hook recreating state or a pending title landing after teardown.", - "status": "established", + "status": "unresolved", "supported_by": [ - "TB-EXP-0120-001", - "TB-EXP-0120-002", - "TB-EXP-0120-003" + "TB-EXP-0120-001" ], "contradicted_by": [], - "decision": "Candidate 09fc5241 completed two full 98-task migrations in 5:27.999 and 5:03.690, then completed clean uninstalls in 2:00.696 and 2:10.504 with all owned artifacts absent. End-to-end setup through active automation was 6:23.650 and 5:41.080, so only the defined full-list migration metric passed the six-minute ceiling twice. Candidate 15027fdc separately proved fresh installer-boundary home-title capture. Final candidate 3b87d7fa then upgraded genuine tagged-v2.2.0 format-3 state whose hook had persisted the sentinel itself as the home subject, cleaned and uninstalled it from quiescent pre-controller state, and preserved an unowned icon-prefixed task against the real native catalog. The normal migration write path is unchanged from the two-cycle candidate.", - "next_preflight": null + "decision": "Candidate 09fc5241 proved the bounded-wave mechanism with two complete 98-task migrations in 5:27.999 and 5:03.690 plus clean uninstalls, but it is not the final reviewed candidate and the run did not begin from the clean application restart required by the release matrix. TB-EXP-0120-002 and TB-EXP-0120-003 remain useful procedural seam observations but had no declared preflights and therefore do not support this capability or release. TB-PRE-0120-002 must close with two complete clean-restart cycles on one exact final candidate before the capability can be re-established.", + "next_preflight": "TB-PRE-0120-002" } ], "experiments": [ @@ -755,7 +753,7 @@ "rendered_proof": "authoritative title readback matched the pre-install home title and clean controller title before uninstall commit; post-commit artifact checks were all absent" }, "confidence": "high", - "applicability": "Directly covers the installer-boundary capture change introduced after TB-EXP-0120-001. Later recovery corrections are separately covered by TB-EXP-0120-003. It does not repeat the unchanged 98-task migration path, so the two full-list timing results remain attributed to candidate 09fc5241 rather than this corrected candidate.", + "applicability": "Procedural seam observation for the installer-boundary capture change introduced after TB-EXP-0120-001. This probe had no declared preflight, so it cannot support a capability or release claim; TB-PRE-0120-002 requires the exact final candidate to repeat the complete release lifecycle.", "supersedes": [], "conflicts": [] }, @@ -795,7 +793,7 @@ "rendered_proof": "authoritative list readback showed the decorated sentinel before prepare; the native cleanup result and durable hook state both matched the exact original title before uninstall commit" }, "confidence": "high", - "applicability": "Directly covers the final candidate's legacy format-3 upgrade, pre-controller uninstall, decorated-home fallback cleanup, and unowned inventory-title scope. Fresh exact-original capture remains covered by TB-EXP-0120-002. The home-authorized settled-failure route is covered by 100-repeat focused tests plus the complete race-enabled suite; it was not induced against an unknown real native call. The two full-list migration timings remain attributed to candidate 09fc5241 because that normal path is unchanged.", + "applicability": "Procedural seam observation for the legacy format-3 upgrade, pre-controller uninstall, decorated-home fallback cleanup, and unowned inventory-title scope. This probe had no declared preflight, so it cannot support a capability or release claim; TB-PRE-0120-002 requires the exact final candidate to repeat the complete release lifecycle.", "supersedes": [], "conflicts": [] } @@ -887,6 +885,26 @@ ], "stop_condition": "Stop after exactly two complete timed install/uninstall cycles against the same built candidate, including authoritative inventory/status checks, post-uninstall artifact checks, and one screenshot at each terminal install and uninstall state. Record the result and close this preflight before using the evidence for merge or release.", "result_experiment_id": "TB-EXP-0120-001" + }, + { + "id": "TB-PRE-0120-002", + "issue": "BEAR-1", + "capability_id": "TB-CAP-BOUNDED-MIGRATION-WAVES", + "status": "pending", + "consulted": [ + "TB-EXP-0120-001", + "TB-EXP-0120-002", + "TB-EXP-0120-003" + ], + "remaining_unknown": "Whether one exact final v2.2.1 candidate containing the native-subagent controller guard and non-destructive skill teardown completes the entire current native-addressable task inventory under six minutes twice after clean Codex restarts, then uninstalls cleanly while preserving user-created files beside the managed skill.", + "single_changed_variable": "Candidate implementation and binary fingerprint: replace candidate 09fc5241 and the later un-preflighted seam canaries with the final reviewed candidate that adds native thread_source authentication for controller registration and removes only the managed skill file plus an empty directory during teardown.", + "held_constant": "Desktop 26.727.51351 and CLI 0.146.0-alpha.9.2; local macOS host; one exact candidate for both cycles; the complete native-addressable inventory present at run time; one Terra-medium controller; Luna-low read-only classifier batches; reconciled max-eight title waves; authoritative inventory/status checks; privacy-safe terminal screenshots; monotonic timers; no code change between cycles.", + "predicted_outcomes": [ + "If the exact final candidate preserves the migration mechanism and safe teardown, both clean-restart cycles will reach migration_complete with zero remaining rows in under six minutes of full-list migration, both prepared uninstalls will return uninstalled true with every ThreadBear-owned artifact absent, and a user-created neighboring skill file will remain byte-exact.", + "If the new source guard blocks the legitimate native controller, concurrent migration or settlement regresses, or teardown mishandles owned versus neighboring files, either cycle will fail registration, exceed six minutes, retain rows or pending state, remove the user file, or leave a ThreadBear-owned artifact; the candidate must not merge or release." + ], + "stop_condition": "Stop after exactly two complete install/migration/uninstall cycles against one built candidate, each beginning from a clean Codex restart and including authoritative status/inventory, terminal timer and screenshot evidence, neighboring-user-file preservation, final artifact checks, and title/archive restoration. Record one linked result experiment and close this preflight before claiming release evidence.", + "result_experiment_id": "" } ] } diff --git a/docs/live-eval.md b/docs/live-eval.md index 326a44c..8ae9744 100644 --- a/docs/live-eval.md +++ b/docs/live-eval.md @@ -8,6 +8,6 @@ Prove complete, all three next-step owners, needs input, blocked, automation, to Run this development-only matrix after installing the candidate with `threadbear install --debug-canaries`; ordinary guided installation never runs it. Rendered proof is mandatory. Verify the running and terminal titles in both the active header and sidebar before their corresponding boundaries. Use one explicit-target migration canary and prove that only the intended mounted row repaints. Confirm that Stop removes the official spinner, leaves the running title, and creates no additional ThreadBear turn. Capture privacy-safe screenshots. -Exercise installation against 0-, 1-, and 200-task inventories, including projectless tasks, exact historical footers, genuine ambiguity, v2-owned decoration, user-authored icons and arrows, concurrent rename/archive, interruption, and clean rerun. Luna-medium workers must be used only for genuine ambiguity and never write titles. Prove adaptive waves under partial capacity, a capacity error after successful spawns, out-of-order completion, one timed-out worker, completed-result preservation, and a final zero-remaining inventory with no retained worker left active or unaccounted for. +Exercise installation against 0-, 1-, and 200-task inventories, including projectless tasks, exact historical footers, genuine ambiguity, v2-owned decoration, user-authored icons and arrows, concurrent rename/archive, interruption, and clean rerun. Read-only Luna-low classifier workers must be used only for genuine ambiguity, in fixed batches of at most 10, and never write titles. Prove bounded classifier waves under partial capacity, a capacity error after successful spawns, out-of-order completion, one eight-minute worker timeout with its one bounded retry, completed-result preservation, and a final zero-remaining inventory with no retained worker left active or unaccounted for. In parallel, prove native title calls run in reconciled waves of at most eight distinct targets, deterministic work does not wait for ambiguous classification, and each settled wave is followed by authoritative inventory before the next wave. Exercise archived-control uninstall without visual or computer control. Use a decorated persisted main that begins archived, a different completed archived controller sentinel, and a separate active initiator. Record candidate SHA and binary hash, native title/archive readbacks, automation identity and deletion, config hashes, and final artifact absence. Interrupt a fresh run after temporary main activation, restore the original archive state, then resume from the same initiator and prove no settled native mutation is duplicated. Separately force local teardown failure before state removal, resume with the still-installed candidate, and require one retained-candidate no-op uninstall after completion. diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 0a97796..63f3fc5 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -6,7 +6,7 @@ Before tagging a stable release: 2. Rename `Unreleased` to `vN.N.N - YYYY-MM-DD` and add a fresh `Unreleased` section. 3. Run `gofmt`, `go test ./...`, `go vet ./...`, both Darwin cross-builds, shell syntax checks, and installer/guide parity checks. Count tracked non-test Go plus shipped bootstrap shell, report the 1,500-line target comparison, and fail the release above the 2,000-line absolute ceiling. 4. In isolated homes, prove install, reinstall, status, inventory, and both uninstall title choices while preserving unrelated AGENTS content and hook definitions in order. Force local teardown to stop before state removal, prove the installed binary remains runnable, resume, and require a retained-candidate second uninstall to be a clean no-op. -5. Exercise 0-, 1-, and 200-task controller migrations. Prove deterministic exact-footer classification, adaptive ambiguity-only Luna waves, retention of successful workers after a later capacity error, bounded worker timeout, out-of-order result accounting, serial explicit native writes, concurrent rename/archive handling, interruption, same-controller resume, clean rerun, and final zero-remaining convergence. +5. Exercise 0-, 1-, and 200-task controller migrations. Prove deterministic exact-footer classification, fixed ambiguity-only Luna-low batches of at most 10, retention of successful workers after a later capacity error, the eight-minute worker deadline and one bounded retry, out-of-order result accounting, reconciled concurrent native-title waves of at most eight distinct targets, deterministic work proceeding without ambiguous-classification delay, concurrent rename/archive handling, interruption, same-controller resume, clean rerun, and final zero-remaining convergence. 6. Install with `threadbear install --debug-canaries`, then run the exact-candidate release matrix in `docs/live-eval.md` from fresh Codex Desktop tasks. Verify the rendered active header and sidebar, capture privacy-safe screenshots outside the public repository, and restore controlled canary titles through the supported native path. 7. With a distinct completed archived controller retained as a sentinel, archive a decorated persisted main task through native control. From another active task, prepare uninstall, clean active titles and the temporarily unarchived main through the exact installed candidate, restore the main archive state, delete the exact automation, and commit local teardown. Verify all native and filesystem results by supported readback without visual or computer control. Repeat with one interruption after temporary activation and resume from the same initiator without a duplicate title mutation. diff --git a/install.sh b/install.sh index 3f5ddcc..a25f37b 100755 --- a/install.sh +++ b/install.sh @@ -1,8 +1,6 @@ #!/bin/sh set -eu - umask 077 - release_base=${THREADBEAR_RELEASE_BASE_URL:-https://github.com/ericlitman/threadbear/releases} selected_version= noninteractive=false diff --git a/site/install b/site/install index 8a753a1..be4e43d 100644 --- a/site/install +++ b/site/install @@ -187,7 +187,7 @@ Restore and verify the main task's exact original archive state before deleting ~/.local/bin/threadbear uninstall --initiator-task-id INITIATOR_ID --noninteractive --confirm --json ``` -Commit refuses unless the prepared owner, clean main title, restored archive state, eligible stopped phase, and settled native operations all match. It removes only ThreadBear's recorded hook entries, managed AGENTS block, installed skill, private state, and binary; partial local teardown is rerunnable and the installed binary is removed last. It preserves unrelated content, hook order, unrelated automations, and archive states. After removal, make no terminal title call and append no ThreadBear footer, because either would decorate the cleaned control-task title again. Ask the user to restart Codex so open sessions cannot keep using snapshotted guidance, and report whether the former home ended archived or active. +Commit refuses unless the prepared owner, clean main title, restored archive state, eligible stopped phase, and settled native operations all match. It removes only ThreadBear's recorded hook entries, managed AGENTS block, installed skill file, private state, and binary; partial local teardown is rerunnable and the installed binary is removed last. It preserves unrelated content—including user-created files beside the managed skill—hook order, unrelated automations, and archive states, and removes the skill directory only when empty. After removal, make no terminal title call and append no ThreadBear footer, because either would decorate the cleaned control-task title again. Ask the user to restart Codex so open sessions cannot keep using snapshotted guidance, and report whether the former home ended archived or active. ## Maintainer verification diff --git a/site/install.sh b/site/install.sh index 3f5ddcc..a25f37b 100755 --- a/site/install.sh +++ b/site/install.sh @@ -1,8 +1,6 @@ #!/bin/sh set -eu - umask 077 - release_base=${THREADBEAR_RELEASE_BASE_URL:-https://github.com/ericlitman/threadbear/releases} selected_version= noninteractive=false