feat(cli): add ai-memory rename-workstream for checkout-local renames (#538)

Workstream names were fixed at `run --new` time and had no correction path,
so a typo outlived the work it labelled and the only escape was starting a
new workstream and abandoning the ledger attached to the old one.

The rename selects by current name or by the stable id `workstreams` prints,
resolving the same (workspace, project, repository, worktree) identity `run`
selects with. Both selectors repeat the checkout predicate: `workstreams.id`
is globally unique, so without it a caller holding an id from another
checkout could retitle a workstream their request never named. An id outside
the resolved scope reads as absent rather than renamable.

It is metadata only. `workstream_events`, `managed_runs`, and
`workstream_native_sessions` all key on `workstreams.id`, so the rename is a
single-row update with nothing to cascade, and `selected_at` and
`updated_at` are deliberately left untouched — relabelling is not activity.
Neither the discovery listing order nor the workstream a bare `ai-memory
run` resumes moves as a side effect of fixing a typo.

The destination is validated exactly like a `--new` name and refused with a
named conflict when another workstream in the same checkout already holds
it, turning the UNIQUE constraint into a typed error instead of a bare
SQLite failure. Renaming a workstream to the name it already has writes
nothing and is not an error.

The new `/workstream/rename` route requires NormalWrite rather than the
NormalRead its sibling discovery read uses, and resolves scope through
`lookup_existing_scope` so a rename never creates the workspace or project
it names. The Docker shell wrapper routes the command through its native
host client alongside `run`, `show`, `continue`, and `workstreams`, since
repository identity is a host resource the helper container cannot see.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: AkitaOnRails <fabioakita@gmail.com>
This commit is contained in:
Victor Cesconetto
2026-08-31 22:23:50 -03:00
committed by GitHub
co-authored by Claude Opus 5 AkitaOnRails
parent b3dfab8ad7
commit ff4f260267
20 changed files with 964 additions and 18 deletions
+15
View File
@@ -247,6 +247,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
force-killing the wrapped process, and a clean stop/uninstall all held;
the service was configured with `StartType Automatic`, but boot-time
startup was not independently exercised (#530).
- Added `ai-memory rename-workstream`, a checkout-local rename for managed
workstreams selectable by current name or by the stable id `workstreams`
prints. Names were fixed at `run --new` time and had no correction path, so
a typo outlived the work it labelled. The rename is metadata only: the
ledger, managed runs, and linked native sessions all key on the workstream
id, and `selected_at` and `updated_at` are deliberately left untouched, so
neither the listing order nor the workstream a bare `ai-memory run` resumes
moves as a side effect of relabelling. The destination is validated exactly
like a `--new` name and refused with a named conflict when another
workstream in the same checkout already holds it; renaming a workstream to
the name it already has writes nothing and is not an error. Both selectors
repeat the checkout predicate, so an id belonging to another workspace,
project, or worktree reads as absent rather than renamable. The Docker
wrapper routes the command through its native host client, since repository
identity is a host resource.
## [1.38.0] - 2026-08-30
+3
View File
@@ -229,6 +229,9 @@ priors are at the [bottom](#influences-and-prior-art).
# List the workstreams that can be selected from this checkout.
ai-memory workstreams
# Fix a name you regret; the ledger and the current selection stay put.
ai-memory rename-workstream --from typo-nmae --to refactor-db
# List open cross-agent handoffs, oldest first, with the id
# `memory_handoff_cancel` needs to clear a stale one.
ai-memory handoffs
+4 -2
View File
@@ -13,6 +13,8 @@
# ai-memory show ... Use that client for host project/harness discovery.
# ai-memory continue ... Use that client to resume the newest linked checkout.
# ai-memory workstreams Use that client to inspect the host checkout identity.
# ai-memory rename-workstream
# Same: the rename is keyed on host repo identity.
#
# Env overrides:
# AI_MEMORY_IMAGE docker image (default: akitaonrails/ai-memory:latest)
@@ -370,7 +372,7 @@ native_host_binary() {
Darwin) os="macos" ;;
*)
echo "ai-memory: the Docker wrapper cannot provide managed host launches on this OS" >&2
echo "install the native release binary to use ai-memory run/show/continue/workstreams" >&2
echo "install the native release binary to use ai-memory run/show/continue/workstreams/rename-workstream" >&2
return 1
;;
esac
@@ -445,7 +447,7 @@ case "${1:-}" in
cmd_upgrade
exit 0
;;
run | show | continue | workstreams)
run | show | continue | workstreams | rename-workstream)
NATIVE_HOST_COMMAND=$1
shift
NATIVE_HOST_BIN=$(native_host_binary)
+31
View File
@@ -47,6 +47,10 @@ pub enum Command {
Handoffs(HandoffsArgs),
/// List recent managed workstreams selectable from the current checkout.
Workstreams(WorkstreamsArgs),
/// Rename a managed workstream in the current checkout. Metadata only:
/// the ledger, linked harnesses, and which workstream a bare
/// `ai-memory run` resumes are all unchanged.
RenameWorkstream(RenameWorkstreamArgs),
/// Search the complete visible event ledger for a managed workstream.
WorkstreamSearch(WorkstreamSearchArgs),
/// Audit the store for likely cross-project contamination (read-only,
@@ -383,6 +387,33 @@ pub struct WorkstreamsArgs {
pub json: bool,
}
/// Arguments for `rename-workstream`.
#[derive(Debug, Args)]
pub struct RenameWorkstreamArgs {
/// Workspace containing the managed workstream. Defaults to the nearest
/// `.ai-memory.toml` marker's `workspace`, else `default`.
#[arg(long)]
pub workspace: Option<String>,
/// Project override. Defaults to the current repository project.
#[arg(long)]
pub project: Option<String>,
/// Current name of the workstream to rename. Names are unique within one
/// checkout, so this is unambiguous wherever `run --workstream` works.
#[arg(long, conflicts_with = "workstream_id")]
pub from: Option<String>,
/// Stable id of the workstream to rename, as printed by `workstreams`.
/// Useful when the current name is awkward to retype.
#[arg(long, conflicts_with = "from")]
pub workstream_id: Option<ai_memory_core::WorkstreamId>,
/// New name. Same rules as `run --new`: non-empty, at most 128
/// characters, no control characters, no slashes.
#[arg(long)]
pub to: String,
/// Emit JSON instead of a readable line.
#[arg(long)]
pub json: bool,
}
/// Arguments for `workstream-search`.
#[derive(Debug, Args)]
pub struct WorkstreamSearchArgs {
+1
View File
@@ -47,6 +47,7 @@ pub mod purge_session;
pub mod read_page;
pub mod reindex;
pub mod rename_project;
pub mod rename_workstream;
pub mod render_shared;
pub mod reorg;
pub mod reset;
@@ -0,0 +1,88 @@
//! Checkout-local rename for managed workstreams.
use ai_memory_core::{RenameManagedWorkstreamRequest, RenamedManagedWorkstream};
use ai_memory_workstream::inspect_repository;
use anyhow::{Context as _, Result, bail};
use crate::cli::RenameWorkstreamArgs;
use crate::config::Config;
use crate::http_client::{ServerEndpoint, post_json};
/// Retitle one workstream selectable from this checkout.
pub async fn run(config: &Config, args: RenameWorkstreamArgs) -> Result<()> {
// clap's `conflicts_with` rejects passing both, but not passing neither:
// the pair is optional on either side, so an empty invocation reaches here
// with nothing to address.
if args.from.is_none() && args.workstream_id.is_none() {
bail!("pass --from NAME or --workstream-id ID to choose the workstream to rename");
}
let cwd = std::env::current_dir().context("getting managed workstream checkout")?;
let repository = inspect_repository(&cwd)?;
let (workspace, project) =
super::resolve_scope(config, args.workspace.as_deref(), args.project.as_deref())?;
let endpoint = ServerEndpoint::from_config_resolving_auth(config).await;
let renamed: RenamedManagedWorkstream = post_json(
&endpoint,
"/workstream/rename",
&RenameManagedWorkstreamRequest {
workspace,
project,
repo_fingerprint: repository.repo_fingerprint,
worktree_fingerprint: repository.worktree_fingerprint,
from: args.from,
workstream_id: args.workstream_id,
to: args.to,
},
)
.await?;
if args.json {
println!("{}", serde_json::to_string_pretty(&renamed)?);
return Ok(());
}
print!("{}", render_human(&renamed));
Ok(())
}
fn render_human(renamed: &RenamedManagedWorkstream) -> String {
if renamed.from == renamed.to {
// A repeated rename is not a failure, but reporting it as a change
// would be a lie: nothing was written.
return format!("Workstream '{}' already has that name.\n", renamed.to);
}
format!(
"Renamed workstream '{}' to '{}'.\n id: {}\n",
renamed.from, renamed.to, renamed.workstream_id
)
}
#[cfg(test)]
mod tests {
use super::*;
use ai_memory_core::WorkstreamId;
fn renamed(from: &str, to: &str) -> RenamedManagedWorkstream {
RenamedManagedWorkstream {
workstream_id: WorkstreamId::new(),
from: from.to_owned(),
to: to.to_owned(),
}
}
#[test]
fn human_output_reports_both_names_and_the_stable_id() {
let renamed = renamed("typo-nmae", "refactor-db");
let rendered = render_human(&renamed);
assert!(rendered.contains("'typo-nmae' to 'refactor-db'"));
// The id is indented far enough that a continuation line cannot be
// mistaken for the start of another record, matching `workstreams`.
assert!(rendered.contains("\n id: "));
assert!(rendered.ends_with('\n'));
}
#[test]
fn renaming_to_the_same_name_does_not_claim_a_change() {
let rendered = render_human(&renamed("stable", "stable"));
assert!(rendered.contains("already has that name"));
assert!(!rendered.contains("Renamed"));
}
}
+1
View File
@@ -97,6 +97,7 @@ async fn main() -> Result<()> {
}
Command::Handoffs(args) => commands::handoffs::run(&config, args).await,
Command::Workstreams(args) => commands::workstreams::run(&config, args).await,
Command::RenameWorkstream(args) => commands::rename_workstream::run(&config, args).await,
Command::WorkstreamSearch(args) => commands::workstream_search::run(&config, args).await,
Command::AuditContamination(args) => {
commands::audit_contamination::run(&config, args).await
+1
View File
@@ -836,6 +836,7 @@ fn managed_host_commands_use_native_path_and_remote_server_without_docker() {
&["show", "--json", "--no-scan"],
&["continue", "--workspace", "work", "--yolo"],
&["workstreams", "--limit", "5", "--json"],
&["rename-workstream", "--from", "old", "--to", "new"],
];
for args in commands {
let output = shell_script_command(&repo_root().join("bin/ai-memory"))
+2 -2
View File
@@ -77,6 +77,6 @@ pub use workstream::{
FinishManagedRunRequest, FinishManagedRunResponse, LinkManagedRunRequest,
ListManagedWorkstreamsRequest, MANAGED_WORKSTREAM_PACKET_MARKER, ManagedRunContextResponse,
ManagedRunStatus, ManagedWorkstreamSummary, NewWorkstreamEvent, PrepareManagedRunRequest,
PrepareManagedRunResponse, UNTRUSTED_MEMORY_NOTICE, WorkstreamCheckpoint, WorkstreamEvent,
WorkstreamEventKind,
PrepareManagedRunResponse, RenameManagedWorkstreamRequest, RenamedManagedWorkstream,
UNTRUSTED_MEMORY_NOTICE, WorkstreamCheckpoint, WorkstreamEvent, WorkstreamEventKind,
};
+36
View File
@@ -284,6 +284,42 @@ pub struct ManagedWorkstreamSummary {
pub linked_harnesses: Vec<AgentKind>,
}
/// Checkout identity and selector for retitling one managed workstream.
///
/// The two selectors are mutually exclusive and the CLI enforces that before
/// the request is built; the server still rejects a body carrying both or
/// neither, since it cannot assume a well-behaved client.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenameManagedWorkstreamRequest {
/// Workspace name resolved by the host CLI.
pub workspace: String,
/// Project name resolved by the host CLI.
pub project: String,
/// Stable repository identity hash.
pub repo_fingerprint: String,
/// Stable worktree identity hash (distinct across linked worktrees).
pub worktree_fingerprint: String,
/// Current name of the workstream to retitle.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from: Option<String>,
/// Stable id of the workstream to retitle, as printed by discovery.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workstream_id: Option<WorkstreamId>,
/// Replacement name.
pub to: String,
}
/// Result of a successful managed-workstream rename.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenamedManagedWorkstream {
/// The workstream that was retitled.
pub workstream_id: WorkstreamId,
/// Name before the rename.
pub from: String,
/// Name after the rename, as stored.
pub to: String,
}
/// Stored workstream event returned by history reads.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkstreamEvent {
+200 -4
View File
@@ -10,11 +10,13 @@ use ai_memory_core::{
AgentKind, AuthLevel, Capability, FinishManagedRunRequest, FinishManagedRunResponse,
LinkManagedRunRequest, ListManagedWorkstreamsRequest, ManagedRunContextResponse, ManagedRunId,
ManagedRunStatus, ManagedWorkstreamSummary, NewWorkstreamEvent, PrepareManagedRunRequest,
PrepareManagedRunResponse, Sanitizer, WorkstreamEventKind, WorkstreamId,
PrepareManagedRunResponse, RenameManagedWorkstreamRequest, RenamedManagedWorkstream, Sanitizer,
WorkstreamEventKind, WorkstreamId,
};
use ai_memory_store::{
FinishWorkstreamRun, PrepareWorkstreamRun, ReaderPool, ScopeResolutionError, StoreError,
WorkstreamSelection, WriterHandle, create_explicit_scope, lookup_existing_scope,
FinishWorkstreamRun, PrepareWorkstreamRun, ReaderPool, RenameWorkstream, ScopeResolutionError,
StoreError, WorkstreamSelection, WorkstreamSelector, WriterHandle, create_explicit_scope,
lookup_existing_scope,
};
use axum::extract::{Path as AxumPath, Query, State};
use axum::http::StatusCode;
@@ -63,6 +65,7 @@ pub fn workstream_router(state: WorkstreamState) -> Router {
.route("/workstream/runs/{run_id}/link", post(link_run))
.route("/workstream/runs/{run_id}/finish", post(finish_run))
.route("/workstream/recent", post(list_recent_workstreams))
.route("/workstream/rename", post(rename_workstream))
.route("/workstream/{workstream_id}/events", get(search_events))
.with_state(state)
}
@@ -460,6 +463,105 @@ async fn list_recent_workstreams(
Json(response).into_response()
}
/// Retitle one checkout-local managed workstream.
///
/// A write surface, so it takes `NormalWrite` rather than the `NormalRead`
/// the sibling discovery read uses. Scope still resolves through
/// `lookup_existing_scope`: a rename never creates the workspace or project
/// it names, and the store repeats the checkout predicate on the id lookup so
/// an id belonging to another checkout reads as absent rather than renamable.
async fn rename_workstream(
State(state): State<WorkstreamState>,
level: Option<Extension<AuthLevel>>,
Json(request): Json<RenameManagedWorkstreamRequest>,
) -> Response {
if let Err(response) = authorize(level, Capability::NormalWrite) {
return response.into_response();
}
for (label, value) in [
("workspace", request.workspace.as_str()),
("project", request.project.as_str()),
("repo_fingerprint", request.repo_fingerprint.as_str()),
(
"worktree_fingerprint",
request.worktree_fingerprint.as_str(),
),
("to", request.to.as_str()),
] {
if value.trim().is_empty() {
return error(StatusCode::BAD_REQUEST, format!("{label} cannot be empty"));
}
if value.len() > MAX_NAME_BYTES {
return error(StatusCode::BAD_REQUEST, format!("{label} is too long"));
}
}
let selector = match (request.from.as_deref(), request.workstream_id) {
(Some(name), None) => {
let name = name.trim();
if name.is_empty() {
return error(StatusCode::BAD_REQUEST, "from cannot be empty");
}
if name.len() > MAX_NAME_BYTES {
return error(StatusCode::BAD_REQUEST, "from is too long");
}
WorkstreamSelector::Name(name.to_string())
}
(None, Some(id)) => WorkstreamSelector::Id(id),
(Some(_), Some(_)) => {
return error(
StatusCode::BAD_REQUEST,
"from and workstream_id are mutually exclusive",
);
}
(None, None) => {
return error(
StatusCode::BAD_REQUEST,
"one of from or workstream_id is required",
);
}
};
let scope = match lookup_existing_scope(
&state.reader,
request.workspace.trim(),
request.project.trim(),
)
.await
{
Ok(scope) => scope,
Err(failure) if failure.is_not_found() => {
return error(StatusCode::NOT_FOUND, failure.to_string());
}
Err(failure) if failure.is_bad_request() => {
return error(StatusCode::BAD_REQUEST, failure.to_string());
}
Err(ScopeResolutionError::Store(message)) => {
return error(StatusCode::INTERNAL_SERVER_ERROR, message);
}
Err(failure) => return error(StatusCode::INTERNAL_SERVER_ERROR, failure.to_string()),
};
let renamed = match state
.writer
.rename_workstream(RenameWorkstream {
workspace_id: scope.workspace_id,
project_id: scope.project_id,
repo_fingerprint: request.repo_fingerprint,
worktree_fingerprint: request.worktree_fingerprint,
selector,
new_name: request.to,
})
.await
{
Ok(renamed) => renamed,
Err(failure) => return store_error_response(failure),
};
Json(RenamedManagedWorkstream {
workstream_id: renamed.workstream_id,
from: renamed.from,
to: renamed.to,
})
.into_response()
}
async fn search_events(
State(state): State<WorkstreamState>,
level: Option<Extension<AuthLevel>>,
@@ -635,7 +737,9 @@ fn parse_run_id(raw: &str) -> Result<ManagedRunId, ApiFailure> {
fn store_error_response(failure: StoreError) -> Response {
let status = match failure {
StoreError::WorkstreamBusy(_) | StoreError::Duplicate(_) => StatusCode::CONFLICT,
StoreError::WorkstreamBusy(_)
| StoreError::Duplicate(_)
| StoreError::WorkstreamNameTaken(_) => StatusCode::CONFLICT,
StoreError::NotFound(_) => StatusCode::NOT_FOUND,
StoreError::InvalidState(_) => StatusCode::BAD_REQUEST,
_ => StatusCode::INTERNAL_SERVER_ERROR,
@@ -869,6 +973,98 @@ mod tests {
(workspace_id, project_id)
}
#[tokio::test]
async fn rename_endpoint_is_scoped_and_reports_selector_misuse() {
let temp = TempDir::new().unwrap();
let store = Store::open(temp.path()).unwrap();
let state = test_state(&store, temp.path());
let (workspace_id, project_id) = seed_scope(&store).await;
let prepared = store
.writer
.prepare_workstream_run(PrepareWorkstreamRun {
selection: WorkstreamSelection::New("typo-nmae".into()),
..prepare_input(workspace_id, project_id, AgentKind::OpenCode, "launcher")
})
.await
.unwrap();
fn request(from: Option<&str>, to: &str) -> RenameManagedWorkstreamRequest {
RenameManagedWorkstreamRequest {
workspace: "default".into(),
project: "managed".into(),
repo_fingerprint: "repo".into(),
worktree_fingerprint: "worktree".into(),
from: from.map(str::to_owned),
workstream_id: None,
to: to.into(),
}
}
let ok = rename_workstream(
State(state.clone()),
None,
Json(request(Some("typo-nmae"), "refactor-db")),
)
.await;
assert_eq!(ok.status(), StatusCode::OK);
let body = to_bytes(ok.into_body(), 64 * 1024).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["from"], "typo-nmae");
assert_eq!(json["to"], "refactor-db");
assert_eq!(json["workstream_id"], prepared.workstream_id.to_string());
// The rename response is metadata like its sibling read: no checkout
// path, and no native session id.
let encoded = String::from_utf8(body.to_vec()).unwrap();
assert!(!encoded.contains("/repo"));
// A name that exists, but in another worktree, must not be reachable.
let mut wrong_worktree = request(Some("refactor-db"), "stolen");
wrong_worktree.worktree_fingerprint = "other-worktree".into();
let response = rename_workstream(State(state.clone()), None, Json(wrong_worktree)).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
// Unknown scopes stay 404 rather than being created by the write.
let mut missing = request(Some("refactor-db"), "stolen");
missing.workspace = "missing".into();
let response = rename_workstream(State(state.clone()), None, Json(missing)).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
// Neither selector, and both selectors, are caller errors the server
// rejects on its own rather than trusting clap to have done it.
let neither = rename_workstream(State(state.clone()), None, Json(request(None, "x"))).await;
assert_eq!(neither.status(), StatusCode::BAD_REQUEST);
let mut both = request(Some("refactor-db"), "x");
both.workstream_id = Some(prepared.workstream_id);
let both = rename_workstream(State(state.clone()), None, Json(both)).await;
assert_eq!(both.status(), StatusCode::BAD_REQUEST);
// An invalid destination name is a 400, not a 500.
let invalid = rename_workstream(
State(state.clone()),
None,
Json(request(Some("refactor-db"), "a/b")),
)
.await;
assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
// A collision is a 409 naming the taken name.
store
.writer
.prepare_workstream_run(PrepareWorkstreamRun {
selection: WorkstreamSelection::New("taken".into()),
..prepare_input(workspace_id, project_id, AgentKind::OpenCode, "other")
})
.await
.unwrap();
let conflict = rename_workstream(
State(state),
None,
Json(request(Some("refactor-db"), "taken")),
)
.await;
assert_eq!(conflict.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn recent_workstream_endpoint_is_checkout_scoped_and_read_only() {
let temp = TempDir::new().unwrap();
+8
View File
@@ -156,6 +156,14 @@ pub enum StoreError {
/// A live `ai-memory run` lease already owns the selected workstream.
#[error("workstream is already active: {0}")]
WorkstreamBusy(String),
/// A workstream rename was rejected because another workstream in the
/// same checkout already holds the destination name. `workstreams` is
/// UNIQUE on (workspace, project, repo, worktree, name), so the rename
/// would violate that constraint; refusing by name gives the caller the
/// collision instead of a bare SQLite error.
#[error("workstream name '{0}' is already taken in this checkout")]
WorkstreamNameTaken(String),
}
impl StoreError {
+296 -2
View File
@@ -70,7 +70,8 @@ pub use session_consolidation::{SESSION_CONSOLIDATION_MAX_ATTEMPTS, SessionConso
pub use users::{TOKEN_HASH_LEN, TOKEN_RAW_LEN, TokenPepper, generate_token, hash_token};
pub use workstream::{
FinishWorkstreamRun, FinishedWorkstreamRun, ManagedRunContext, PrepareWorkstreamRun,
PreparedWorkstreamRun, StoredManagedRunStatus, StoredWorkstreamSummary, WorkstreamSelection,
PreparedWorkstreamRun, RenameWorkstream, RenamedWorkstream, StoredManagedRunStatus,
StoredWorkstreamSummary, WorkstreamSelection, WorkstreamSelector,
};
pub use writer::{StartupContextAcceptance, WriterHandle};
@@ -170,7 +171,7 @@ mod tests {
ActorContext, AgentKind, HandoffAcceptance, HandoffId, HandoffState, LinkTarget,
ManagedRunId, NewHandoff, NewObservation, NewPage, NewSession, NewWorkstreamEvent,
ObservationId, ObservationKind, PageId, PagePath, ProjectId, Sanitized, Sanitizer,
SessionId, Tier, UserId, WorkspaceId, WorkstreamEventKind,
SessionId, Tier, UserId, WorkspaceId, WorkstreamEventKind, WorkstreamId,
};
use rusqlite::{Connection, params};
use sha2::{Digest, Sha256};
@@ -5985,6 +5986,299 @@ mod tests {
assert!(visible(&store, mine, theirs_proj).await.is_empty());
}
/// Create `names` as workstreams in one checkout, newest last, and hand
/// back each one's `(workstream, run)` pair. Preparing is the only way to
/// create a workstream, so every seeded row also owns a live run lease.
async fn seed_workstreams(
store: &Store,
ws: WorkspaceId,
proj: ProjectId,
names: &[&str],
) -> Vec<(WorkstreamId, ManagedRunId)> {
let mut ids = Vec::new();
for name in names {
let prepared = store
.writer
.prepare_workstream_run(PrepareWorkstreamRun {
selection: WorkstreamSelection::New((*name).into()),
..managed_prepare_input(ws, proj, name)
})
.await
.unwrap();
ids.push((prepared.workstream_id, prepared.run_id));
}
ids
}
fn rename_input(
ws: WorkspaceId,
proj: ProjectId,
selector: WorkstreamSelector,
to: &str,
) -> RenameWorkstream {
RenameWorkstream {
workspace_id: ws,
project_id: proj,
repo_fingerprint: "repo".into(),
worktree_fingerprint: "worktree".into(),
selector,
new_name: to.into(),
}
}
#[tokio::test]
async fn rename_retitles_by_name_or_id_and_keeps_the_stable_id() {
let tmp = TempDir::new().unwrap();
let store = Store::open(tmp.path()).unwrap();
let (ws, proj) = open_managed_scope(&store, "proj").await;
let seeded = seed_workstreams(&store, ws, proj, &["alpha", "beta"]).await;
let ids: Vec<WorkstreamId> = seeded.iter().map(|(id, _)| *id).collect();
let run_ids: Vec<ManagedRunId> = seeded.iter().map(|(_, run)| *run).collect();
let by_name = store
.writer
.rename_workstream(rename_input(
ws,
proj,
WorkstreamSelector::Name("alpha".into()),
"alpha-renamed",
))
.await
.unwrap();
assert_eq!(by_name.from, "alpha");
assert_eq!(by_name.to, "alpha-renamed");
// The id is what the ledger and `workstream-search` key on, so a
// rename that minted a new one would orphan the history.
assert_eq!(by_name.workstream_id, ids[0]);
let by_id = store
.writer
.rename_workstream(rename_input(
ws,
proj,
WorkstreamSelector::Id(ids[1]),
"beta-renamed",
))
.await
.unwrap();
assert_eq!(by_id.from, "beta");
assert_eq!(by_id.workstream_id, ids[1]);
// Both new names are selectable, which is the whole point of the
// rename. Seeding left a live lease on each workstream, so release
// them first: `Named` selection refuses a busy workstream, and that
// refusal would mask whether the name resolved at all.
for run_id in &run_ids {
assert!(store.writer.cancel_managed_run(*run_id).await.unwrap());
}
for name in ["alpha-renamed", "beta-renamed"] {
store
.writer
.prepare_workstream_run(PrepareWorkstreamRun {
selection: WorkstreamSelection::Named(name.into()),
..managed_prepare_input(ws, proj, name)
})
.await
.unwrap();
}
}
#[tokio::test]
async fn rename_does_not_reorder_the_discovery_listing() {
// Relabelling is not activity. If a rename bumped `updated_at` the
// renamed row would jump its peers, and if it bumped `selected_at` a
// bare `ai-memory run` would silently resume a different workstream —
// both as a side effect of fixing a typo.
//
// Three rows, not two: `is_current` leads the ORDER BY, so the
// current workstream sorts first whatever its timestamps say. Only a
// pair of non-current rows can show an `updated_at` bump at all.
let tmp = TempDir::new().unwrap();
let store = Store::open(tmp.path()).unwrap();
let (ws, proj) = open_managed_scope(&store, "proj").await;
seed_workstreams(&store, ws, proj, &["oldest", "middle", "current"]).await;
async fn listing(store: &Store, ws: WorkspaceId, proj: ProjectId) -> Vec<(String, bool)> {
store
.reader
.recent_workstreams(ws, proj, "repo".into(), "worktree".into(), 20)
.await
.unwrap()
.into_iter()
.map(|row| (row.name, row.current))
.collect()
}
let before = listing(&store, ws, proj).await;
assert_eq!(
before,
[
("current".to_owned(), true),
("middle".to_owned(), false),
("oldest".to_owned(), false),
]
);
store
.writer
.rename_workstream(rename_input(
ws,
proj,
WorkstreamSelector::Name("oldest".into()),
"oldest-renamed",
))
.await
.unwrap();
// `oldest-renamed` must stay behind `middle`: it was renamed, not
// worked on. A bumped `updated_at` would put it second.
assert_eq!(
listing(&store, ws, proj).await,
[
("current".to_owned(), true),
("middle".to_owned(), false),
("oldest-renamed".to_owned(), false),
]
);
// Renaming the current workstream must not hand `current` to anyone
// else either, which a bumped `selected_at` on the wrong row would do.
store
.writer
.rename_workstream(rename_input(
ws,
proj,
WorkstreamSelector::Name("oldest-renamed".into()),
"oldest-again",
))
.await
.unwrap();
let after = listing(&store, ws, proj).await;
assert_eq!(after[0], ("current".to_owned(), true));
assert_eq!(after[2].0, "oldest-again");
}
#[tokio::test]
async fn rename_refuses_a_name_another_workstream_holds() {
let tmp = TempDir::new().unwrap();
let store = Store::open(tmp.path()).unwrap();
let (ws, proj) = open_managed_scope(&store, "proj").await;
seed_workstreams(&store, ws, proj, &["alpha", "beta"]).await;
let failure = store
.writer
.rename_workstream(rename_input(
ws,
proj,
WorkstreamSelector::Name("alpha".into()),
"beta",
))
.await
.unwrap_err();
assert!(
matches!(&failure, StoreError::WorkstreamNameTaken(name) if name == "beta"),
"expected a named collision, got {failure:?}"
);
// Renaming onto its own current name writes nothing and is not an
// error, so a repeated command stays safe.
let noop = store
.writer
.rename_workstream(rename_input(
ws,
proj,
WorkstreamSelector::Name("alpha".into()),
"alpha",
))
.await
.unwrap();
assert_eq!((noop.from.as_str(), noop.to.as_str()), ("alpha", "alpha"));
}
#[tokio::test]
async fn rename_cannot_reach_a_workstream_outside_the_resolved_scope() {
// `workstreams.id` is globally unique, so the id selector would be a
// cross-scope write primitive without the checkout predicate.
let tmp = TempDir::new().unwrap();
let store = Store::open(tmp.path()).unwrap();
let (mine, mine_proj) = open_managed_scope(&store, "shared-name").await;
let theirs = store
.writer
.get_or_create_workspace("other-workspace")
.await
.unwrap();
let theirs_proj = store
.writer
.get_or_create_project(theirs, "shared-name", None)
.await
.unwrap();
let theirs_ids = seed_workstreams(&store, theirs, theirs_proj, &["theirs"]).await;
let theirs_workstream = theirs_ids[0].0;
seed_workstreams(&store, mine, mine_proj, &["mine"]).await;
let by_id = store
.writer
.rename_workstream(rename_input(
mine,
mine_proj,
WorkstreamSelector::Id(theirs_workstream),
"stolen",
))
.await
.unwrap_err();
assert!(
matches!(by_id, StoreError::NotFound(_)),
"an id from another workspace must read as absent, got {by_id:?}"
);
let by_name = store
.writer
.rename_workstream(rename_input(
mine,
mine_proj,
WorkstreamSelector::Name("theirs".into()),
"stolen",
))
.await
.unwrap_err();
assert!(matches!(by_name, StoreError::NotFound(_)));
// The other workspace's row is untouched by either attempt.
let theirs_names: Vec<String> = store
.reader
.recent_workstreams(theirs, theirs_proj, "repo".into(), "worktree".into(), 20)
.await
.unwrap()
.into_iter()
.map(|row| row.name)
.collect();
assert_eq!(theirs_names, ["theirs"]);
}
#[tokio::test]
async fn rename_rejects_a_name_that_run_new_would_reject() {
let tmp = TempDir::new().unwrap();
let store = Store::open(tmp.path()).unwrap();
let (ws, proj) = open_managed_scope(&store, "proj").await;
seed_workstreams(&store, ws, proj, &["alpha"]).await;
for bad in ["", " ", "has/slash", "has\\backslash", "ctrl\u{1}char"] {
let failure = store
.writer
.rename_workstream(rename_input(
ws,
proj,
WorkstreamSelector::Name("alpha".into()),
bad,
))
.await
.unwrap_err();
assert!(
matches!(failure, StoreError::InvalidState(_)),
"name {bad:?} should be rejected, got {failure:?}"
);
}
}
#[tokio::test]
async fn prepare_expires_a_stale_lease_and_reopens_the_workstream() {
let tmp = TempDir::new().unwrap();
+143
View File
@@ -160,6 +160,48 @@ pub struct StoredWorkstreamSummary {
pub linked_harnesses: Vec<AgentKind>,
}
/// How a rename addresses the workstream it retitles.
///
/// Both forms stay inside the caller's resolved scope: an id that belongs to
/// another workspace, project, or worktree is treated as absent rather than
/// renamed, so a guessed identifier cannot reach across the scope boundary
/// that `workstreams` is keyed on.
#[derive(Debug, Clone)]
pub enum WorkstreamSelector {
/// Address by current name, unique within one checkout.
Name(String),
/// Address by stable id, as printed by the discovery listing.
Id(WorkstreamId),
}
/// Store-level input for retitling one managed workstream.
#[derive(Debug, Clone)]
pub struct RenameWorkstream {
/// Workspace holding the workstream.
pub workspace_id: WorkspaceId,
/// Project holding the workstream.
pub project_id: ProjectId,
/// Stable repository identity hash.
pub repo_fingerprint: String,
/// Stable worktree identity hash.
pub worktree_fingerprint: String,
/// Which workstream to retitle.
pub selector: WorkstreamSelector,
/// Replacement name, validated exactly like a `--new` name.
pub new_name: String,
}
/// Outcome of a successful rename.
#[derive(Debug, Clone)]
pub struct RenamedWorkstream {
/// The workstream that was retitled.
pub workstream_id: WorkstreamId,
/// Name before the rename.
pub from: String,
/// Name after the rename.
pub to: String,
}
struct FinishRunRow {
workstream: Vec<u8>,
agent_wire: String,
@@ -879,6 +921,107 @@ pub(crate) fn list_recent(
Ok(summaries)
}
/// Retitle one checkout-local workstream.
///
/// Names are metadata: `workstream_events`, `managed_runs`, and
/// `workstream_native_sessions` all key on `workstreams.id`, so a rename is a
/// single-row update with nothing to cascade. `selected_at` and `updated_at`
/// are deliberately left alone — relabelling is not activity, and bumping
/// either would reorder the discovery listing (and, for `selected_at`, change
/// which workstream a bare `ai-memory run` resumes) as a side effect of
/// fixing a typo.
///
/// A rename onto the workstream's own current name succeeds without writing,
/// so a repeated command is not an error. A rename onto a name another
/// workstream in the same checkout already holds is refused before the
/// update, turning the `UNIQUE` constraint into a named error rather than a
/// bare SQLite failure.
pub(crate) fn rename(
conn: &mut Connection,
input: &RenameWorkstream,
) -> StoreResult<RenamedWorkstream> {
validate_workstream_name(&input.new_name)?;
let new_name = input.new_name.trim();
let tx = conn.transaction()?;
let found = match &input.selector {
WorkstreamSelector::Name(name) => tx
.query_row(
"SELECT id, name FROM workstreams \
WHERE workspace_id = ?1 AND project_id = ?2 \
AND repo_fingerprint = ?3 AND worktree_fingerprint = ?4 AND name = ?5",
params![
input.workspace_id.as_bytes(),
input.project_id.as_bytes(),
input.repo_fingerprint,
input.worktree_fingerprint,
name,
],
|row| Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, String>(1)?)),
)
.optional()?,
// The scope predicate stays on the id lookup too: `workstreams.id` is
// globally unique, so without it a caller holding an id from another
// checkout could retitle a workstream their request never named.
WorkstreamSelector::Id(id) => tx
.query_row(
"SELECT id, name FROM workstreams \
WHERE id = ?1 AND workspace_id = ?2 AND project_id = ?3 \
AND repo_fingerprint = ?4 AND worktree_fingerprint = ?5",
params![
id.as_bytes(),
input.workspace_id.as_bytes(),
input.project_id.as_bytes(),
input.repo_fingerprint,
input.worktree_fingerprint,
],
|row| Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, String>(1)?)),
)
.optional()?,
};
let (id_bytes, current_name) = found.ok_or_else(|| {
StoreError::NotFound(match &input.selector {
WorkstreamSelector::Name(name) => format!("managed workstream '{name}'"),
WorkstreamSelector::Id(id) => format!("managed workstream {id}"),
})
})?;
let workstream_id = WorkstreamId::from_slice(&id_bytes)?;
if current_name == new_name {
return Ok(RenamedWorkstream {
workstream_id,
from: current_name.clone(),
to: current_name,
});
}
let taken: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM workstreams \
WHERE workspace_id = ?1 AND project_id = ?2 \
AND repo_fingerprint = ?3 AND worktree_fingerprint = ?4 \
AND name = ?5 AND id IS NOT ?6)",
params![
input.workspace_id.as_bytes(),
input.project_id.as_bytes(),
input.repo_fingerprint,
input.worktree_fingerprint,
new_name,
workstream_id.as_bytes(),
],
|row| row.get(0),
)?;
if taken {
return Err(StoreError::WorkstreamNameTaken(new_name.to_string()));
}
tx.execute(
"UPDATE workstreams SET name = ?1 WHERE id = ?2",
params![new_name, workstream_id.as_bytes()],
)?;
tx.commit()?;
Ok(RenamedWorkstream {
workstream_id,
from: current_name,
to: new_name.to_string(),
})
}
/// Reader-side context range assigned to one managed run.
pub(crate) fn run_context(
conn: &Connection,
+21
View File
@@ -33,6 +33,7 @@ use crate::session_consolidation::SessionConsolidationJob;
use crate::users::{self, TOKEN_HASH_LEN};
use crate::workstream::{
FinishWorkstreamRun, FinishedWorkstreamRun, PrepareWorkstreamRun, PreparedWorkstreamRun,
RenameWorkstream, RenamedWorkstream,
};
/// Result of atomically claiming the startup context assembled for one hook.
@@ -480,6 +481,10 @@ pub(crate) enum WriteCmd {
input: FinishWorkstreamRun,
reply: oneshot::Sender<StoreResult<FinishedWorkstreamRun>>,
},
RenameWorkstream {
input: RenameWorkstream,
reply: oneshot::Sender<StoreResult<RenamedWorkstream>>,
},
Shutdown,
}
@@ -1911,6 +1916,18 @@ impl WriterHandle {
rx.await.map_err(|_| StoreError::WriterClosed)?
}
/// Retitle one checkout-local workstream, leaving its selection and
/// activity timestamps untouched.
pub async fn rename_workstream(
&self,
input: RenameWorkstream,
) -> StoreResult<RenamedWorkstream> {
let (tx, rx) = oneshot::channel();
self.send(WriteCmd::RenameWorkstream { input, reply: tx })
.await?;
rx.await.map_err(|_| StoreError::WriterClosed)?
}
async fn send(&self, cmd: WriteCmd) -> StoreResult<()> {
self.inner
.tx
@@ -2650,6 +2667,10 @@ fn worker_loop(mut conn: Connection, mut rx: mpsc::Receiver<WriteCmd>) {
let result = crate::workstream::finish_run(&mut conn, &input);
send_or_warn(reply, result, "finish_workstream_run");
}
WriteCmd::RenameWorkstream { input, reply } => {
let result = crate::workstream::rename(&mut conn, &input);
send_or_warn(reply, result, "rename_workstream");
}
}
}
tracing::debug!("writer thread exiting cleanly");
+1
View File
@@ -458,6 +458,7 @@ rename-project move-project move-session
uninstall auth user
completions handoffs purge-session
compact
rename-workstream
```
Run `ai-memory --help` for the full tree.
+9 -8
View File
@@ -1796,6 +1796,7 @@ docker run --rm akitaonrails/ai-memory:latest --help # full subcommand tree
| `show [--json]` | host wrapper or native binary | Choose a client-local checkout and installed managed harness, or return structured discovery data without launching; remote servers never provide checkout paths |
| `continue [--workspace NAME]` | host wrapper or native binary | From any directory, revalidate and resume the newest client-local managed checkout; accepts `--yolo` and `--fresh` but no harness-native arguments |
| `workstreams [--workspace NAME] [--project NAME] [--limit N] [--json]` | host wrapper or native binary | List recent workstreams selectable from the current checkout, including current selection and linked harnesses, without exposing paths or native session ids |
| `rename-workstream (--from NAME \| --workstream-id ID) --to NAME [--workspace NAME] [--project NAME] [--json]` | host wrapper or native binary | Retitle one workstream selectable from the current checkout; metadata only, so the stable id, the ledger, the listing order, and the current selection are unaffected |
| `workstream-search [query]` | managed child or thin HTTP client | Search the complete visible managed-workstream ledger; the managed child receives its workstream id automatically |
| `status` | `docker exec` | Counts, paths, derived-index diagnostics, and passive LLM/embedding provider health |
| `search "<query>"` | `docker exec` | Wiki FTS5 search + bounded source authority; use MCP `memory_query` for entity/graph/vector RRF |
@@ -2088,14 +2089,14 @@ warns that relabeling system directories such as `/home` can make the host
inoperable. Docker documents `label=disable` in the
[`docker run` security options](https://docs.docker.com/reference/cli/docker/container/run/#security-opt).
`ai-memory run`, `ai-memory show`, `ai-memory continue`, and
`ai-memory workstreams` are the exceptions: the current wrapper intercepts them
and starts a cached checksum-verified native client on the host, where local
checkouts, harness executables, and session stores exist. It preserves an
explicit remote `AI_MEMORY_SERVER_URL`. If one of these commands logs
`data_dir=/data`, cannot find a checkout, or cannot find `codex`, `claude`, or
another host executable, refresh the stale wrapper with `ai-memory upgrade` on
that client machine.
`ai-memory run`, `ai-memory show`, `ai-memory continue`,
`ai-memory workstreams`, and `ai-memory rename-workstream` are the exceptions:
the current wrapper intercepts them and starts a cached checksum-verified native
client on the host, where local checkouts, harness executables, and session
stores exist. It preserves an explicit remote `AI_MEMORY_SERVER_URL`. If one of
these commands logs `data_dir=/data`, cannot find a checkout, or cannot find
`codex`, `claude`, or another host executable, refresh the stale wrapper with
`ai-memory upgrade` on that client machine.
### Docker compose alternative
+17
View File
@@ -64,6 +64,23 @@ activity. Each row includes the linked harnesses and stable workstream id; the
response does not expose checkout paths, repository fingerprints, or native
session ids.
Names are chosen at `--new` time and can be corrected later:
```bash
ai-memory rename-workstream --from typo-nmae --to refactor-db
ai-memory rename-workstream --workstream-id 01a04092-… --to refactor-db
```
The two selectors are mutually exclusive; the id is the one `workstreams`
prints. The rename is metadata only. Names are unique per checkout, so a
destination another workstream already holds is refused rather than merged,
and the destination is validated exactly like a `--new` name. Because the
ledger, linked harnesses, and managed runs all key on the workstream id rather
than its name, nothing else moves — including which workstream a bare
`ai-memory run` resumes, and the listing order, both of which stay put because
a rename deliberately does not touch `selected_at` or `updated_at`. A run that
is already live keeps displaying the name it launched with until it exits.
## Project-first launcher
`ai-memory show` reverses the usual `cd` then `run` flow: choose a local
+4
View File
@@ -100,10 +100,14 @@ main() {
AI_MEMORY_DOCKER="${fake_docker}" AI_MEMORY_NATIVE_BIN="${fake_native}" \
AI_MEMORY_WRAPPER_TEST_LOG="${wrapper_log}" \
bin/ai-memory workstreams --limit 5 --json
AI_MEMORY_DOCKER="${fake_docker}" AI_MEMORY_NATIVE_BIN="${fake_native}" \
AI_MEMORY_WRAPPER_TEST_LOG="${wrapper_log}" \
bin/ai-memory rename-workstream --from typo-nmae --to refactor-db
assert_contains "${wrapper_log}" "run codex --yolo"
assert_contains "${wrapper_log}" "show --json --no-scan"
assert_contains "${wrapper_log}" "continue --workspace work --yolo"
assert_contains "${wrapper_log}" "workstreams --limit 5 --json"
assert_contains "${wrapper_log}" "rename-workstream --from typo-nmae --to refactor-db"
log "Creating temporary alternate root"
mkdir -p \
+83
View File
@@ -1244,6 +1244,89 @@ jq -e 'length == 0' <<<"$other_json" >/dev/null || {
exit 1
}
# `ai-memory rename-workstream` must correct a name without disturbing anything
# keyed on the workstream. The id is stable, so the ledger, managed runs, and
# linked native sessions follow the rename, and neither the listing order nor
# the workstream a bare `ai-memory run` resumes may move as a side effect of
# relabelling.
before_rename_json=$workstreams_json
adopt_id=$(jq -r '.[] | select(.name == "edge-adopt") | .workstream_id' \
<<<"$workstreams_json")
rename_json=$(cd "$REPO" && "$BIN" --data-dir "$DATA" rename-workstream \
--from edge-adopt --to edge-adopt-fixed --json)
jq -e --arg id "$adopt_id" \
'.workstream_id == $id and .from == "edge-adopt" and .to == "edge-adopt-fixed"' \
<<<"$rename_json" >/dev/null || {
printf 'rename-workstream did not report the retitled workstream\n' >&2
printf '%s\n' "$rename_json" >&2
exit 1
}
renamed_json=$(cd "$REPO" && "$BIN" --data-dir "$DATA" workstreams --json)
jq -e --arg id "$adopt_id" \
'.[0].current and .[0].name == "edge-adopt-fixed" and .[0].workstream_id == $id' \
<<<"$renamed_json" >/dev/null || {
printf 'rename moved the listing order or the current selection\n' >&2
printf '%s\n' "$renamed_json" >&2
exit 1
}
jq -e '[.[] | select(.name == "edge-adopt")] | length == 0' \
<<<"$renamed_json" >/dev/null || {
printf 'the name the rename replaced survived in the listing\n' >&2
printf '%s\n' "$renamed_json" >&2
exit 1
}
# A name another workstream in this checkout already holds is refused, as is a
# name `run --new` would reject. Both must fail before writing anything.
if (cd "$REPO" && "$BIN" --data-dir "$DATA" rename-workstream \
--from edge-kimi --to edge-adopt-fixed) >/dev/null 2>&1; then
printf 'rename-workstream took a name another workstream already holds\n' >&2
exit 1
fi
if (cd "$REPO" && "$BIN" --data-dir "$DATA" rename-workstream \
--from edge-kimi --to 'nested/name') >/dev/null 2>&1; then
printf 'rename-workstream took a name run --new would reject\n' >&2
exit 1
fi
# The stable id is unique across every checkout, so the scope predicate has to
# repeat on the id selector: an id belonging to another worktree must read as
# absent rather than as a renamable target.
if (cd "$OTHER_REPO" && "$BIN" --data-dir "$DATA" rename-workstream \
--project "$(basename "$REPO")" --workstream-id "$adopt_id" \
--to hijacked) >/dev/null 2>&1; then
printf 'rename-workstream reached a workstream outside the calling checkout\n' >&2
exit 1
fi
# Renaming to the name it already carries writes nothing and is not an error.
noop_json=$(cd "$REPO" && "$BIN" --data-dir "$DATA" rename-workstream \
--from edge-adopt-fixed --to edge-adopt-fixed --json)
jq -e '.from == .to and .to == "edge-adopt-fixed"' <<<"$noop_json" >/dev/null || {
printf 'renaming to the current name was not reported as a no-op\n' >&2
printf '%s\n' "$noop_json" >&2
exit 1
}
# Renaming back by id has to restore the listing exactly. Anything the refused
# attempts, the no-op, or the rename itself touched -- a bumped `updated_at`, a
# moved selection, a reordered row -- surfaces as a diff here.
rename_back=$(cd "$REPO" && "$BIN" --data-dir "$DATA" rename-workstream \
--workstream-id "$adopt_id" --to edge-adopt)
grep -q "Renamed workstream 'edge-adopt-fixed' to 'edge-adopt'" \
<<<"$rename_back" || {
printf 'human rename output did not report both names\n' >&2
printf '%s\n' "$rename_back" >&2
exit 1
}
after_rename_json=$(cd "$REPO" && "$BIN" --data-dir "$DATA" workstreams --json)
[ "$after_rename_json" = "$before_rename_json" ] || {
printf 'a rename round trip left the listing changed\n' >&2
diff <(printf '%s\n' "$before_rename_json") \
<(printf '%s\n' "$after_rename_json") >&2 || true
exit 1
}
if [ "$DETERMINISTIC_ONLY" = 1 ]; then
printf 'deterministic managed-workstream acceptance passed\n'
exit 0