Skip to content

v0.78.0

Choose a tag to compare

@davidfarah2003 davidfarah2003 released this 10 Oct 03:34
e07adbc

Cotal 0.78.0 is out!

Grab it: npm i cotal-ai@0.78.0

Changes in this release

  • SecretStore gains create(key, value), a write that stores the value only while the key is absent and resolves whether it did. FsSecretStore implements it with an exclusive create. ensureCalloutAuth, ensureIssuer, ensureOwnerSecret and the hosted auth plane's instance identity now write their first value through it and adopt the stored value when another caller created it first. Before, two concurrent first calls on one space each minted a value, the later put replaced the earlier one, and the earlier caller kept a value the store no longer held: one IdP subject could derive two owners, a bearer could be signed by a key the stored issuer does not publish, and a prepared broker could preload a callout account the auth service never loads. A store of your own must now implement create as one atomic step; see the upgrade guide.
  • The endpoint credential reload, the membership feed's rw credential reload and the backup snapshot sink now share one promise deadline helper instead of three hand-copied races. Timeouts, error types and messages are unchanged.
  • A spawn acceptance replayed to a resubmission now names the owner the manager allocated. On a user-auth mesh, a sibling instance, a restarted instance or a bind race rebuilt the acceptance from the goal index or the goal terminal with the static mesh's local owner, and the terminal rebuild also used the composite owner.actor id as the actor, so a caller that addressed the agent by it reached a principal that was never allocated. The goal-index acceptance floor now records the owner beside the actor and uid, and the terminal rebuild splits the user-mode principal. A floor written by an older manager names no owner, so a resubmission served from it is refused unavailable.
  • Loading an installed extension now waits up to five seconds in total for another cotal process that is installing, removing or updating extensions, instead of failing at once. Before, a command that started a moment before the other process finished its work was refused, though a retry would have succeeded. The wait does not block the process: timers, requests and cancellation keep running while the load is queued. If the other process is still running when the five seconds are up, the load fails with "another extension update or mutation is in progress (pid ...) - retry once it finishes" when the update pass is held, or with "extension install/remove is in progress (pid ...) - retry after the active cotal ext command finishes" when only the install/remove lock is held.
  • The delivery-reconnect smoke's stop/close race cell now requires the public membership stop to settle only after the fresh connection sent the retained consumer's delete and the broker refused it for the admin profile, and its consumer-identity mutation removes only the retained stream and consumer name. The mutation used to delete watch.consumer as well, which disabled the watch's epoch guards and reddened three other cells while the race cell stayed green: a stop with nothing retained resolves at once, and the bound-only consumer census is empty either way, so the cell could not tell that stop from one that waited for fresh-epoch cleanup. The cell's starting census also waits for the broker to mark the new consumer push-bound, which can trail the watch and reddened unmutated runs.
  • The spawn-name actor-token smoke reads the CLI's refuseUnmintableNameOrExit call sites from the parsed spawn.ts: it requires two call expressions and checks that each keys the door on a .mode === "user" comparison. The cells used to count text matches after regex comment stripping, so a call moved into a string stayed green, the declaration counted as a call, a quote change failed the mode cell, and a // inside a string hid the rest of its line.
  • A followed manager call from a user-mode seat, such as cotal_spawn, now runs its bearer command, opens its control connection and resolves the manager as preparation before the submission starts. A failure in any of those steps used to be reported as a submission whose outcome was unknown and that "may have been accepted or executed", although nothing had been published, and the error's details were dropped. It now surfaces as its own error, and a stop or the call's deadline during those steps reports that the request was not run. The describe spends what is left of the deadline, so a manager that never answers it is still reported as unanswered. Only the command publish runs as the submission. A goal follow's prepare now receives the follow's deadline. At that deadline its signal aborts with a TimeoutError, and a prepare that has not settled reports that the request was not run, while a describe it started after other steps still reports its own silence. A describe whose signal aborts with a TimeoutError now ends as its own deadline would. A call's resolve repair after a failed-precondition now spends what is left of the call's deadline instead of starting a fresh one.
  • PrimitiveSpec.opensScope is replaced by scope, the ScopeKind a primitive opens or null. A scope row in PRIMITIVES now has to name a member of ScopeKind to compile, and both engines read that kind from the table instead of casting the primitive's name to it, so a new scope primitive left out of the union no longer reaches the journal as a kind its readers do not know. Code that read spec.opensScope reads spec.scope !== null.
  • The static captured-write check (L2032) now reads a scope's body position from the syntax table, as the argument just before the options bag. It used to name fanOut and conclave as the primitives whose body is the second argument, so a scope primitive added to the table with its body second had its first argument walked and a captured write in its body passed validate with no error. The five current scope primitives validate as before.
  • Whether a scope calls the handler itself is now its own dispatches row in scopeTraits, and performScope reads it there. Before, it inferred dispatch from whether the scope hashes a subject, so changing conclave's hashesSubject still typechecked and then silently dropped the conclave's durable request id, its cancel re-check after begin and its count against the effect ceiling (L4009), and let a migration walk into a settled conclave and call the handler's openConclave where it used to refuse with UnwalkableScope. Shipped behaviour is unchanged.
  • waitUntil now enters its step through the same code as every other effect. The replay verdicts, the cancellation checks, the host stop, request-id recovery and the begin append were written out a second time for waitUntil, so a rule changed in one copy could leave waits replaying, stopping or recovering differently from the rest. Behaviour is unchanged.
  • A pty seat whose declared startup confirmation never appears is now stopped the way a graceful stop ends it, so a child that ignores SIGTERM is killed 3 seconds later. The in-process pty runtime, which createRuntime("pty") uses for every launch, sent SIGTERM once and never escalated, so such a child kept running behind a failed gate and the launch resolved as uncertain instead of failing. Both pty runtimes now share one StartupConfirmGate from @cotal-ai/seat, which owns the prompt match, the 15-second window, the failure message and the exit diagnostic.
  • cotal meshes add --from and the manual-registration policy refresh now refuse a fetched discovery document that is not JSON with the URL and the content type that answered, for example a catch-all route serving /.well-known/cotal-mesh as 200 text/html. They used to print the --user-auth-file sentence, which named neither and told the operator to re-export a file. JSON served under any content type is still accepted, and --user-auth-file keeps its sentence.
  • The pid contract smoke now writes its malformed auth-pidfile fixture at the path the auth stop reads, derived from the stop's own record descriptor. It used to read PID_PATH, which the CLI's auth module does not export, so every run fell back to a hand-spelled name, and moving the auth record would have failed the cell for the wrong reason.
  • A mesh registry directory that exists but cannot be read, such as one without read permission or a regular file in its place, now makes cotal meshes and every other command that reads the registry exit 1 with its path and the read error. Before, the registry read returned no meshes for any read error, so cotal meshes exited 0 with "no meshes registered" and printed no rows under --json. An absent registry directory is still an empty registry.
  • The tmux and cmux runtimes no longer leave a seat's launch script on disk. Each spawn wrote the seat's rendered launch env, including any model-provider keys and spawn env values it forwards, to an owner-only script in a temp directory and never removed it, so the values stayed there after the agent started and exited. The script now removes its directory before it starts the agent and does not start the agent when that removal fails, and a spawn whose tmux window or cmux tab fails to open removes it at once. privateLaunch and paneCommand now return the command together with the script's directory.
  • A Claude Code session driven by hand without a control endpoint is now refused with a message that names COTAL_CONTROL_SOCKET and COTAL_CONTROL_TOKEN, and the configuration reference says a hand-driven Claude Code or jcode session sets both. The reference used to say COTAL_CREDS and COTAL_SERVERS were enough, and the refusal named neither variable.
  • COTAL_TLS now takes the same spellings as the other connector on/off flags. It was read as on only for the exact value 1, so true, yes or on started the session without demanding TLS and, against a broker that accepts plaintext, connected unencrypted with no warning. 1, true, yes and on in any case now turn TLS on, 0, false, no and off keep the join link's choice, and any other value is refused at startup naming the variable.
  • A seat no longer shows its session a run turn whose deadline has passed. The seat pulls pending turns every 15 seconds, and a turn it had pulled before its deadline was injected on any frame until the next pull dropped it, even though the run had already failed the step and the manager refuses its yield. Expired turns are now left out of the injected context while the next pull settles them.
  • On macOS and Linux, the process that becomes an OpenCode agent's opencode serve now writes its own pid to the agent's serve.pid before the server starts. Before, the launcher wrote the record only after the server had started, so a launcher killed in between left a server holding the agent's database with no record, and the next launch of that name started a second server on the same database. Windows still records the server from the launcher after the start.
  • The tmux driver's closeWindow, closeWindowIfHeld and closePane, and so the tmux runtime's stop and reap and the tmux TerminalLayout's close, now read a target as already gone only from the start of tmux's own can't find window: , can't find session: or can't find pane: report, and throw on any other failure. Before, the phrase could match anywhere in stderr or the error message, so a close that failed on a missing socket or an unsafe socket directory, with one of those words in the socket path or the target, returned as a finished close, or for closeWindowIfHeld as a window it did not hold, while the window kept running.
  • tmux.serverPid, and so TmuxRuntime.reap, now throws a tmux: couldn't read the server pid: ... error with the original error as its cause when tmux cannot be queried, as the driver's other queries do. It used to rethrow the bare Command failed: tmux display-message -p #{pid} or spawnSync tmux ENOENT error. A server that is not running still reads as no server.
  • POST /manager-service-authority now applies the exchange face's refusal budgets, as POST /exchange does. A missing or wrong loopback capability counts in the invalid-capability window, and a refused IdP token or request counts against the face's refused-exchange budget, per peer on the public face. After 30 refusals in a minute a further refusal answers 429, where the route used to answer 401 or 403 without limit. ManagerServiceAuthorityCtx now requires the failures and badCaps windows, so a host that builds it without them no longer compiles; see the upgrading guide.
  • handleManagerServiceAuthority now answers a body over 64 KiB with 413 and a body that is not JSON with 400 itself, and answers a JSON null body with the same 400 as a body without idpToken. Before, those rejections escaped the returned promise, so a host that served the export behind its own http.createServer exited on such a request instead of answering it. The auth service's own listeners already answered 413 and 400.
  • cotal meshes add --from now fetches the discovery document through the same pinned fetch the registration policy refresh uses for that document. The fetch gives up after 5 s instead of 10 s, and a refused redirect prints the pinned-fetch refusal, so registration and the later refresh judge one URL alike.
  • cotal meshes add --from checks for a terminal once, before it asks to fetch the discovery document. A second copy of the same check ran before the fetched pins were shown and could never refuse, because the first had already passed in the same process. Both copies loaded the prompt module with a dynamic import although the command already imports it. Behavior is unchanged.
  • cotal clean store|all, cotal backup create and cotal meshes rm now treat a process record as stopped only when it is absent, empty, or names a pid the kernel reports gone. Before, they read any record not proven alive as stopped, so a garbled record, an extension removal reservation, or a pid whose liveness the kernel would not report let clean delete the JetStream store and space identity, backup snapshot a store that could still be written, and meshes rm drop the record of a running broker, while cotal down refused the same records. cotal status now shows such a pid as liveness unknown (<pid>) instead of stale pidfile.
  • cotal down --preserve-state now checks each presence row once, where it is read, for a card with a non-empty string id and a string name, and refuses the cut as malformed when one fails. A deleted key is still the only row it reads as absent. Before, a participant that wrote null, false, 0 or "" under its own presence key was skipped as if its key were deleted, so the cut preserved state while that participant was live, and a card with no name was named undefined (<id>) in the unmanaged-endpoints refusal.
  • The cotal up root pin no longer takes an auth-mode argument. It already pinned the same way for auth and --open meshes and never read the argument, but every caller still worked out the mesh's auth mode to pass it, and the -f path parsed broker.auth only for that. Behavior is unchanged.
  • cotal up -f <manifest> --idp <url> now applies the flag to the effective manifest, so the --dry-run plan names the IdP the launch pins. Before, the plan named the manifest's broker.idp while the launch used the flag. The flag and broker.idp now get the URL rules the launch applies to the IdP: a value that is not https (or http on a loopback IP literal), or that carries credentials, a query or a fragment, is refused before the plan prints. Before, such a value passed the dry run and the launch refused it.
  • cotal up now waits for a manager it starts to come up before it reports it. A refresh used to print ✓ restored in the background: manager (pid N) and exit 0 the moment it had spawned the process, so a manager that refused at boot and exited seconds later, for example while a crashed predecessor's lease had not expired, read as restored. A manager that exits before it comes up, or whose pidfile another cotal supervise takes over first, is now reported as a degraded control plane naming its log, and a refresh exits nonzero.
  • A static reconciliation sweep that stops before it plans any alias (its provisioner credential or connection fails, or a slot row cannot be read) now reports state: failed in the manager status response with the reason in lastSweep.error, and cotal status --components prints it. Before, it read as an idle sweep that found nothing, and the reason was only in the manager log. The manager cluster document moves to revision 27 for the changed status output. Each cotal status --components row now stays on one line: a control character in a reported reason, such as a newline in a stored row's field name, prints as a \uXXXX escape.
  • A hosted delivery close() whose lease release is in flight while the delivery connection drops now waits for the connection to reconnect, up to the broker-gone window (COTAL_DELIVERY_BROKER_GONE_MS, 15 seconds by default) counted from the drop, and repeats the release once on the reconnected connection before it decides. Before, a lease read or delete written to the dropped socket, or buffered while it was down, timed out after 5 seconds although the broker answered again, so close() rejected, every later close() and drain() returned that rejection, and a successor was refused until the bucket TTL expired the row. A release that still cannot be confirmed, including one whose connection does not come back within that window, still rejects.
  • The manager no longer carries a static-spawn refusal for endpointCapabilities. The check read the field through a cast because no spawn request type declares it: launch specs and roster files reject the key, the served spawn contract does not accept it, and the control-plane start op does not copy it, so no spawn could reach the refusal. A change that lets a spawn carry endpoint capabilities declares the field on the spawn options and adds the static refusal against it in the same change.
  • remoteManagerClient now exports remoteManagerGoalIndexScanRequest(state, actor, registrationProof, serveEpoch), which builds the goal-index scan request the stock manager sends. An embedding that supplies scanGoalIndex no longer has to spell the request envelope itself, and the stock manager builds its scan request with the same helper.
  • A launch on a runtime that cannot stream its terminal (tmux, cmux, orca, herdr) whose process dies before it joins the mesh is now reported as exited on launch and its seat is freed. Before, the readiness wait had no exit signal on those runtimes, so it reported the launch as uncertain after the full readiness window and kept the dead seat managed. While a launch waits, the manager asks the runtime for the seat's status every second and once more when the readiness window closes, so a window shorter than a second is covered too. It confirms an exit with the runtime's waitForExit, the same proof the exit watch uses, and reports the launch as uncertain when that proof has not arrived a second after the window closes. The failure carries no last output, because these runtimes stream none.
  • A remote cotal supervise now resolves the workspace root once and hands every auth provider call the same store and state directory. Before, most of its provider calls resolved the root again when they ran, so a .cotal/ directory created or removed nearer the working directory while the manager was up could send later calls to a different store than the one its identity was loaded from. The state directory is now the space's own .cotal/auth/space.<hex>, the one every other provider caller passes, in place of a path built from the raw space name. A remote supervise only runs when that directory holds no user-auth state, so the provider still reaches the host through the registry entry and behavior is otherwise unchanged.
  • The manager's comments on the launch owner-equality tier, the endpoint door's narrowings, the presence-snapshot await before name allocation and the user-mode spawn rollback no longer cite internal review labels. Each keeps the reason it gives. There is no behavior change.
  • The mesh-ask smoke's manager stand-in now serves the reserved cancel, committing the relay's goal cancelled as the manager does, so a cancelled ask and the discharge withdraw their relay instead of failing on a command the stand-in never described. Cell 7 passes again and the suite runs cells 8 to 11 and its summary. Cell 11's resumed escalation now records its attempt index on its binding, as every checkpoint binding has since each attempt began binding its own deadline.
  • A wait's outer timeout token and a waitUntil observation's pause token are now each derived in one named function, which both the arm site and the pause-ownership table call. Before, the purpose strings were spelled by hand at every arm site and again in the ownership table, so renaming one side still typechecked and the run authority then refused the pause at run time with L4000. Behavior is unchanged.
  • A manager-hosted or --local run's wait(down(agent), { timeout }) and wait(replied(agent), { timeout }) now wait, and resolve null when the timeout passes. They used to fail with L4000 before waiting: both armed their timeout without recording it on the step, and the run refuses to mint a wait timer the step did not record. wait now records the timers for every event before it branches to the agent-addressed waits.
  • When a detached web dashboard does not become HTTP-ready before the deadline, the timeout error now ends with the outcome of the last readiness probe: the probed api/meta URL and the fetch error, the HTTP status, or the space and pid the server answered with. Before, every probe failure was swallowed and the error said only that the dashboard timed out, so a refused connection, a rejected request and a different process on the port all looked the same. The retry cadence and the deadline are unchanged. A mismatched space or pid is summarized with bounded fields and the URL and outcome after ; last probe are capped at 300 bytes, counting the ... that marks a cut, so whatever answers the port cannot inflate the error.

What's Changed

  • fix(connector-core): prepare a followed user-mode manager call before its submission starts by @davidfarah2003 in #3577
  • fix(cli): check preserve-state presence rows where they are read so a null row no longer passes as a tombstone by @davidfarah2003 in #3579
  • fix(runtime): record a wait's timers before it branches to down and replied by @davidfarah2003 in #3576
  • fix(connector): read COTAL_TLS through the shared on/off flag parser by @davidfarah2003 in #3578
  • fix(connector): name the control pair a hand-driven Claude Code session needs by @davidfarah2003 in #3582
  • fix(seat,manager): kill a pty child that ignores SIGTERM after its startup confirmation fails by @davidfarah2003 in #3589
  • fix(manager): report a static reconcile sweep that fails before planning as failed by @davidfarah2003 in #3593
  • fix(cli): let clean, backup and meshes rm proceed only over process records proven stopped by @davidfarah2003 in #3592
  • fix(tmux,cmux): remove a seat's launch script once it runs by @davidfarah2003 in #3596
  • fix(manager): name the allocated owner in a replayed spawn acceptance by @davidfarah2003 in #3594
  • fix(auth)!: create first-use auth secrets only while the store key is absent by @davidfarah2003 in #3597
  • fix(workspace): refuse an unreadable mesh registry instead of reporting it empty by @davidfarah2003 in #3600
  • test(runtime): serve cancel from the mesh-ask stand-in manager by @davidfarah2003 in #3604
  • fix(auth): answer manager-service authority body refusals in the exported handler by @davidfarah2003 in #3613
  • refactor(cli): drop the auth-mode argument the up root pin ignores by @davidfarah2003 in #3619
  • test(core): read the spawn-name smoke's CLI door calls from the parsed source by @davidfarah2003 in #3621
  • fix(connector-opencode): record the serve's pid before the serve starts by @davidfarah2003 in #3617
  • test(workspace): derive the pid smoke's auth fixture from the stop's record by @davidfarah2003 in #3624
  • fix(connector-core): stop showing a run turn after its deadline by @davidfarah2003 in #3625
  • fix(manager): report a launch that dies on a non-streaming runtime as exited on launch by @davidfarah2003 in #3632
  • fix(cli): apply up -f --idp to the effective manifest by @davidfarah2003 in #3635
  • refactor(core): share one promise deadline helper across endpoint, membership feed and backup by @davidfarah2003 in #3640
  • fix(auth)!: count manager-service authority refusals against the exchange face's budgets by @davidfarah2003 in #3637
  • fix(website): share one clipboard handler between the two prompt buttons by @davidfarah2003 in #3636
  • refactor(manager): bind the remote supervise provider call context once by @davidfarah2003 in #3639
  • fix(cli): wait for a started manager to come up before up reports it by @davidfarah2003 in #3638
  • refactor(runtime): derive the wait-timeout and observe pause tokens in one place by @davidfarah2003 in #3647
  • fix(website): rewrite links and images that docs pages name in HTML and JSX attributes by @davidfarah2003 in #3651
  • fix(manager): drop the unreachable static endpointCapabilities refusal by @davidfarah2003 in #3654
  • fix(manager): drop internal review labels from the launch tier and spawn comments by @davidfarah2003 in #3649
  • fix(lang): read a scope's body position from the syntax table in the captured-write check by @davidfarah2003 in #3658
  • fix(lang)!: name a primitive's scope kind in the syntax table by @davidfarah2003 in #3657
  • fix(lang): classify whether a scope dispatches in its scope traits by @davidfarah2003 in #3660
  • fix(manager): build the goal-index scan request with one shared builder by @davidfarah2003 in #3659
  • fix(tmux): wrap a failed serverPid query like the driver's other queries by @davidfarah2003 in #3664
  • fix(cli): check for a terminal once on meshes add --from by @davidfarah2003 in #3669
  • fix(cli): name the URL and content type when a fetched discovery document is not JSON by @davidfarah2003 in #3670
  • fix(cli): fetch the meshes add --from discovery document through pinnedFetch by @davidfarah2003 in #3666
  • fix(delivery): repeat a hosted close() release once after a reconnect by @davidfarah2003 in #3672
  • refactor(lang): enter waitUntil's step through the same code as every other effect by @davidfarah2003 in #3673
  • test(core): grade the membership stop/close race on the epoch its stop settles on by @davidfarah2003 in #3675
  • fix(web): report the last probe outcome when detached readiness times out by @drakeo338 in #2668
  • fix(tmux): read an already-closed target only from the start of tmux's report by @davidfarah2003 in #3681
  • fix(workspace): wait briefly for a held extension lock before refusing a load by @drakeo338 in #2657
  • fix(scripts): stop treating a localhost host name as an operator literal by @L4XB in #2743

Full Changelog: v0.77.1...v0.78.0