Compare commits

..
Author SHA1 Message Date
shadowdaoandClaude Opus 5.5 d73a19a9e7 docs: note the stop guard order, background polls, clone skip and trap order
Secret Scan / scan (push) Successful in 18s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Build Container / build-container (pull_request) In progress
Build App (Preview) / test (pull_request) Successful in 6m21s
Secret Scan / scan (pull_request) Successful in 8s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 5m55s
Build App (Preview) / build-linux (pull_request) Successful in 8m29s
Build App (Preview) / build-windows (pull_request) Successful in 10m36s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 13:08:05 -08:00
shadowdaoandClaude Opus 5.5 c467308513 fix(aws): keep the access point id when a removal keeps the saved files
remove_aws_resources cleared access_point_id even without discard_data, though the
access point stays; the project could then move hosts and orphan it. The id is
now kept, so a later start reuses it, a later removal with "Also remove the
access point" deletes it, and moving the project is refused until then (the
message and the Host section say so).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 13:08:05 -08:00
shadowdaoandClaude Opus 5.5 be2825d344 fix(aws): the file viewer's change poll no longer counts as workspace activity
Its 2 s sha256sum poll now carries X-Triple-C-Background, so an open viewer does
not keep an idle AWS workspace up. Opening and saving a file stay foreground;
Docker is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 13:08:05 -08:00
shadowdaoandClaude Opus 5.5 33eb63d907 fix(aws): forget a stale stopped mark on a project's unattached target id
The death watch's stopped marks are keyed by target id, and an unattached AWS
project's id (http://unattached.invalid/<id>/) is reused by every later unattached
spell, but only an endpoint URL's mark was ever removed. A stale mark could make
the watch report a live, billed task as stopped. Registering a tunnel now clears
the project's placeholder mark, and reconcile forgets it before asking ECS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 13:08:05 -08:00
shadowdaoandClaude Opus 5.5 884a665e7d fix(aws): close terminals before the stop guard's push and bound it in the container
The guard pushed and recorded `pushed` while terminals (and Claude in them) were
still open; the TERM hook then skipped its push, so later edits were lost. Stop now
closes the project's exec sessions and auth bridge first, then pushes, records the
marker and stops the task. A failed push still aborts the stop with the status
restored; the auth bridge is re-armed and the error says the terminals were closed.

The script also runs under `/usr/bin/timeout -k 5` ending 10 s before the client's
timeout: the agent does not kill an exec its client gave up on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 13:07:54 -08:00
shadowdaoandClaude Opus 5.5 170ba1e94d fix(container): skip repos still cloning, re-check idleness after the idle push, trap TERM before background work
- clone_repos records `<folder>\tcloning` as each clone begins; triple-c-wip-push
  skips a folder whose last state is `cloning` while the clone is not done (dry run
  reports "cloning": true; RepoState/TS carry it, the stop guard lists it as
  "still cloning — nothing to push" and never asks because of it).
- The idle watcher re-checks quiet() after a successful push and goes back to
  watching if someone came back during it.
- The entrypoint installs its stop trap before the idle watcher and the background
  clone start, so a TERM in between still runs aws_before_stop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 13:07:53 -08:00
22 changed files with 511 additions and 59 deletions

No files matched your search

+8 -3
View File
@@ -352,7 +352,9 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
(`aws:userid` contains `:`). Workspace users can't deregister or delete task definitions
(`DeregisterTaskDefinition` is `*`-only in IAM): removal leaves revisions ACTIVE for an admin
(`aws/README.md`). Stop: the stop guard (`commands/stop_guard.rs`) dry-runs `triple-c-wip-push`
through the agent, may push first, then writes `pushed` or `discard` to `/run/triple-c/stop-reason`
through the agent, closes the project's terminals and auth bridge (so nothing is written after the
push's snapshot), may push first (under an in-container `timeout`, since the agent does not kill an
exec the client gave up on), then writes `pushed` or `discard` to `/run/triple-c/stop-reason`
(root-owned; the entrypoint's TERM hook reads it, and the idle watcher writes `idle`) so the hook
doesn't push twice; the marker is cleared if StopTask fails. Claude Code's sandbox is forced off in
the generated settings (bubblewrap can't create namespaces on Fargate, verified live).
@@ -400,10 +402,13 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
marker says the app handled it. The idle watcher runs as root under a supervisor and signals the
entrypoint's own PID (not PID 1, which is docker-init), so idle and ECS stops share one trap.
Idle = the agent's `last_activity_unix` older than N minutes (open-but-silent attaches/tunnels
don't count; requests with `X-Triple-C-Background: 1`, e.g. the auth bridge's poll, never do),
don't count; requests with `X-Triple-C-Background: 1` — the auth bridge's and the file viewer's polls — never do),
no `claude`, no scheduler job, no running clone, and no unsaved git work: with the WIP push on the
watcher pushes *before* signalling and stays up if that fails; with it off, dirty repos keep the
workspace up. An idle stop exits 75 (`aws::tasks::IDLE_EXIT_CODE`) so the death watch can tell it
workspace up; activity during the watcher's push sends it back to watching. A repo the background
clone is still writing (`/run/triple-c-clone/repos` last says `cloning`, no `done`) is skipped by
`triple-c-wip-push` (dry run: `"cloning": true`). The stop trap is set before any background work
starts. An idle stop exits 75 (`aws::tasks::IDLE_EXIT_CODE`) so the death watch can tell it
from a crash (`classify_stop` → the `ContainerExited` toast).
**The `TRIPLEC_` env prefix is reserved** on both hosts (`RESERVED_ENV_PREFIXES`; `workspace.rs`
drops custom variables in it): the entrypoint's settings live there.
+128 -25
View File
@@ -1230,8 +1230,10 @@ fn validate_aws_update(
})
{
return Err(
"This project still has AWS resources (a task, its data's access point or task \
definitions) where it runs now; remove the workspace's AWS resources first"
"This project still has AWS resources (a task, its saved files' access point or task \
definitions) where it runs now; remove the workspace's AWS resources first — \
including the access point (\"Also remove the workspace's file-system access \
point\"), which stays when the saved files are kept"
.into(),
);
}
@@ -1517,36 +1519,58 @@ pub async fn stop_project_container(
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
// The stop guard's choice, before anything else is torn down: a push that
// fails aborts the stop with the project as it was (auth bridge included).
// AWS: the terminals and the auth bridge close *before* the stop guard's
// push, so nothing written after its snapshot is silently dropped (the
// entrypoint's hook skips its own push once `pushed` is recorded). A push
// that fails aborts the stop with the status as it was; the auth bridge
// is re-armed, and the error says the terminals were closed.
let mut note = None;
let guard_target = if project.is_aws() { ContainerTarget::for_project(&project) } else { None };
if project.is_aws() {
if wip == Some(WipChoice::Push) {
emit_progress(&app_handle, &project_id, "Pushing unsaved work...");
}
note = aws_guard_before_stop(
let close_first = async {
state.auth_bridge.stop(&project_id).await;
if let Some(container) = guard_target.as_ref() {
state.exec_manager.close_sessions_for_container(container.id()).await;
}
if wip == Some(WipChoice::Push) {
emit_progress(&app_handle, &project_id, "Pushing unsaved work...");
}
};
let guarded = aws_guard_before_stop(
&state.projects_store,
&project_id,
project.status.clone(),
guard_target.as_ref(),
wip,
super::stop_guard::PUSH_TIMEOUT,
close_first,
)
.await?;
.await;
match guarded {
Ok(n) => note = n,
Err(e) => {
if project.auth_bridge_enabled {
if let Some(target) = guard_target.clone() {
state
.auth_bridge
.start(project_id.clone(), target, app_handle.clone(), state.projects_store.clone())
.await;
}
}
return Err(e);
}
}
} else {
// Drop host listeners first: they only make sense while the container runs.
state.auth_bridge.stop(&project_id).await;
}
// Drop host listeners first: they only make sense while the container runs.
state.auth_bridge.stop(&project_id).await;
if project.is_aws() {
// A failed StopTask leaves a billed task running, so unlike Docker it
// is not papered over: the project keeps its status and the user
// sees why (expired SSO, typically), and can retry.
// sees why (expired SSO, typically), and can retry. Its terminals
// and auth bridge were closed before the stop guard's step above.
emit_progress(&app_handle, &project_id, "Stopping task...");
if let Some(container) = ContainerTarget::for_project(&project) {
state.exec_manager.close_sessions_for_container(container.id()).await;
}
let settings = state.settings_store.get();
if let Err(e) = ProjectHost::for_project(&project).stop_project(&project, &settings).await {
log::warn!("Stop failed for project {}: {}", project_id, e);
@@ -1578,22 +1602,36 @@ pub async fn stop_project_container(
Ok(note)
}
/// The AWS stop guard's step before StopTask ([`super::stop_guard::prepare_stop`]).
/// A failed push puts `previous` back, so the project is as it was.
async fn aws_guard_before_stop<R: super::stop_guard::WipRunner>(
/// Appended to a failed stop-guard push: its terminals were closed first.
const TERMINALS_CLOSED_FOR_PUSH: &str =
"Its terminals were closed before the push, so nothing written after it could be lost; \
open them again to keep working.";
/// The AWS stop guard's step before StopTask ([`super::stop_guard::prepare_stop`]),
/// after `close_first` (the project's terminals and auth bridge): whatever
/// they write after the push's snapshot would otherwise be lost, since the
/// entrypoint's hook does not push again after `pushed`. A failed push puts
/// `previous` back, so the project's status is as it was.
async fn aws_guard_before_stop<R, F>(
store: &crate::storage::projects_store::ProjectsStore,
project_id: &str,
previous: ProjectStatus,
runner: Option<&R>,
wip: Option<WipChoice>,
push_timeout: std::time::Duration,
) -> Result<Option<String>, String> {
close_first: F,
) -> Result<Option<String>, String>
where
R: super::stop_guard::WipRunner,
F: std::future::Future<Output = ()>,
{
close_first.await;
match super::stop_guard::prepare_stop(runner, wip, push_timeout).await {
Ok(note) => Ok(note),
Err(e) => {
log::warn!("Project {}: WIP push before stop failed: {}", project_id, e);
let _ = store.update_status(project_id, previous);
Err(e)
Err(format!("{e}\n\n{TERMINALS_CLOSED_FOR_PUSH}"))
}
}
}
@@ -1655,13 +1693,26 @@ pub async fn remove_aws_resources(
ProjectHost::for_project(&project)
.remove_aws_resources(&project, &settings, discard_data)
.await?;
state.projects_store.set_aws_runtime(&project_id, None, None, None)?;
state
.projects_store
.set_aws_runtime(&project_id, None, access_point_after_removal(&project, discard_data), None)?;
state
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))
}
/// The access point a removal leaves stored: kept when the saved files were
/// kept (it is still there, a later start reuses it and a later removal
/// with `discard_data` deletes it; and while it is stored the project can't
/// move away from it — [`validate_aws_update`]), gone when they were not.
fn access_point_after_removal(project: &Project, discard_data: bool) -> Option<String> {
if discard_data {
return None;
}
project.aws.as_ref().and_then(|a| a.access_point_id.clone())
}
fn check_aws_resources_removable(project: &Project) -> Result<(), String> {
if !project.is_aws() {
return Err("This project does not run on AWS".into());
@@ -1910,8 +1961,11 @@ pub async fn reconcile_aws_projects(state: State<'_, AppState>) -> Result<Vec<Pr
// A target id left from the last run is a loopback URL whose port a new
// tunnel (any project's) may now own; until a re-attach registers, the
// project gets an id no tunnel can have (m9).
// A stopped mark a past unattached spell left on that id is forgotten
// first: reconcile asks ECS now, and a stale mark would make the death
// watch report a live, billed task as stopped.
for p in &projects {
if let Some(id) = crate::host::aws_host::unattached_container_id(p) {
if let Some(id) = crate::host::aws_host::prepare_unattached(p) {
let _ = state.projects_store.set_container_id(&p.id, Some(id));
}
}
@@ -2632,6 +2686,30 @@ mod tests {
assert!(validate_aws_update(&running, &edited, &hosts).is_ok());
}
#[test]
fn a_removal_that_keeps_the_saved_files_keeps_their_access_point_and_the_project_in_place() {
let hosts = vec![host("h1")];
let mut stored = aws_project();
stored.aws.as_mut().unwrap().access_point_id = Some("fsap-1".into());
assert_eq!(access_point_after_removal(&stored, false), Some("fsap-1".into()));
assert_eq!(access_point_after_removal(&stored, true), None);
// What the store holds after a keep-the-files removal still blocks a move.
let mut after = stored.clone();
let a = after.aws.as_mut().unwrap();
a.task_arn = None;
a.task_definition_arn = None;
a.access_point_id = access_point_after_removal(&stored, false);
let mut local = after.clone();
local.host_id = None;
local.aws_placement = None;
local.aws = None;
let e = validate_aws_update(&after, &local, &hosts).unwrap_err();
assert!(e.contains("access point"), "{e}");
// After a discarding removal it can move.
after.aws.as_mut().unwrap().access_point_id = access_point_after_removal(&stored, true);
assert!(validate_aws_update(&after, &local, &hosts).is_ok());
}
#[test]
fn a_project_with_aws_resources_cannot_be_moved_off_its_host() {
let hosts = vec![host("h1"), host("h2")];
@@ -2715,10 +2793,11 @@ mod tests {
let (_d, store, id) = stop_store(ProjectStatus::Running);
store.update_status(&id, ProjectStatus::Stopping).unwrap();
let fake = Fake::new(Ok(("push to origin failed: denied".into(), 1)));
let e = aws_guard_before_stop(&store, &id, ProjectStatus::Running, Some(&fake), Some(WipChoice::Push), std::time::Duration::from_secs(5))
let e = aws_guard_before_stop(&store, &id, ProjectStatus::Running, Some(&fake), Some(WipChoice::Push), std::time::Duration::from_secs(5), async {})
.await
.unwrap_err();
assert!(e.contains("denied"), "{e}");
assert!(e.contains("terminals were closed"), "{e}");
assert_eq!(store.get(&id).unwrap().status, ProjectStatus::Running);
assert!(fake.markers.lock().unwrap().is_empty());
}
@@ -2729,7 +2808,7 @@ mod tests {
let (_d, store, id) = stop_store(ProjectStatus::Running);
store.update_status(&id, ProjectStatus::Stopping).unwrap();
let fake = Fake::new(Ok(("pushed".into(), 0)));
let note = aws_guard_before_stop(&store, &id, ProjectStatus::Running, Some(&fake), Some(WipChoice::Push), std::time::Duration::from_secs(5))
let note = aws_guard_before_stop(&store, &id, ProjectStatus::Running, Some(&fake), Some(WipChoice::Push), std::time::Duration::from_secs(5), async {})
.await
.unwrap();
assert_eq!(note, None);
@@ -2738,6 +2817,30 @@ mod tests {
assert_eq!(fake.calls.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn the_terminals_close_before_the_stop_guard_pushes_or_records_anything() {
use crate::commands::stop_guard::tests::Fake;
for wip in [Some(WipChoice::Push), Some(WipChoice::Discard), None] {
let (_d, store, id) = stop_store(ProjectStatus::Running);
let fake = Fake::new(Ok(("pushed".into(), 0)));
let seen_at_close = std::sync::Mutex::new(None);
aws_guard_before_stop(&store, &id, ProjectStatus::Running, Some(&fake), wip, std::time::Duration::from_secs(5), async {
*seen_at_close.lock().unwrap() =
Some((fake.calls.lock().unwrap().len(), fake.markers.lock().unwrap().len()));
})
.await
.unwrap();
assert_eq!(*seen_at_close.lock().unwrap(), Some((0, 0)), "{wip:?}: closed first");
}
// Stop's own code closes them through that step, not after it.
let src = include_str!("project_commands.rs");
let stop = &src[src.find("pub async fn stop_project_container").unwrap()..];
let stop = &stop[..stop.find("\n}\n").unwrap()];
let close = stop.find("close_sessions_for_container").unwrap();
assert!(close < stop.find("aws_guard_before_stop(").unwrap());
assert!(stop.find("auth_bridge.stop").unwrap() < stop.find("aws_guard_before_stop(").unwrap());
}
#[test]
fn a_new_aws_project_takes_repos_not_folders_and_no_runtime_handles() {
let hosts = vec![host("h1")];
+56 -10
View File
@@ -19,6 +19,10 @@ pub const DRY_RUN_TIMEOUT: Duration = Duration::from_secs(30);
pub const PUSH_TIMEOUT: Duration = Duration::from_secs(300);
/// Cap on the push output carried into an error.
const MAX_OUTPUT: usize = 4000;
/// How much sooner than the client's timeout the in-container `timeout`
/// ends the script (its TERM, then 5 s to the KILL): an exec the client gives
/// up on is not killed by the agent, so the container bounds it itself.
const IN_CONTAINER_MARGIN: Duration = Duration::from_secs(10);
/// Where the entrypoint's stop hook looks for the app's decision (Task 7's
/// file, which the idle watcher also writes `idle` to).
@@ -30,8 +34,13 @@ pub const MARKER_DISCARD: &str = "discard";
/// The workspace, as the stop guard uses it.
pub trait WipRunner: Sync {
/// Run the WIP script as `claude`: `(combined output, exit code)`.
fn run(&self, args: Vec<String>) -> impl Future<Output = Result<(String, i64), String>> + Send;
/// Run the WIP script as `claude`: `(combined output, exit code)`. The
/// caller waits at most `limit`; the run must not outlive it.
fn run(
&self,
args: Vec<String>,
limit: Duration,
) -> impl Future<Output = Result<(String, i64), String>> + Send;
/// Record `marker` in [`STOP_REASON_FILE`] (as root: the directory is root's).
fn write_stop_reason(&self, marker: &str) -> impl Future<Output = Result<(), String>> + Send;
/// Remove [`STOP_REASON_FILE`] (as root), so the hook pushes as configured again.
@@ -39,10 +48,8 @@ pub trait WipRunner: Sync {
}
impl WipRunner for ContainerTarget {
async fn run(&self, args: Vec<String>) -> Result<(String, i64), String> {
let mut cmd = vec![WIP_PUSH.to_string()];
cmd.extend(args);
self.exec_oneshot_as("claude", cmd, Vec::new()).await
async fn run(&self, args: Vec<String>, limit: Duration) -> Result<(String, i64), String> {
self.exec_oneshot_as("claude", wip_command(args, limit), Vec::new()).await
}
async fn write_stop_reason(&self, marker: &str) -> Result<(), String> {
@@ -68,6 +75,22 @@ impl WipRunner for ContainerTarget {
}
}
/// The script's argv under coreutils `timeout`, ending it
/// [`IN_CONTAINER_MARGIN`] before the client's `limit` (KILL 5 s after the
/// TERM), so a push the app gave up on is not left running in the workspace.
pub fn wip_command(args: Vec<String>, limit: Duration) -> Vec<String> {
let secs = limit.saturating_sub(IN_CONTAINER_MARGIN).as_secs().max(1);
let mut cmd = vec![
"/usr/bin/timeout".to_string(),
"-k".to_string(),
"5".to_string(),
secs.to_string(),
WIP_PUSH.to_string(),
];
cmd.extend(args);
cmd
}
/// The root command that forgets a recorded marker (a fixed argv).
pub fn clear_stop_reason_command() -> Vec<String> {
vec!["rm".into(), "-f".into(), STOP_REASON_FILE.into()]
@@ -132,7 +155,7 @@ pub async fn unsaved_work<R: WipRunner>(runner: Option<&R>, timeout: Duration) -
.into(),
);
};
let res = tokio::time::timeout(timeout, runner.run(vec!["--dry-run".into()])).await;
let res = tokio::time::timeout(timeout, runner.run(vec!["--dry-run".into()], timeout)).await;
match res {
Err(_) => unchecked("The workspace did not answer the check for unsaved work in time.".into()),
Ok(Err(e)) => unchecked(format!("The workspace could not be asked about unsaved work: {e}")),
@@ -175,7 +198,8 @@ pub async fn before_stop<R: WipRunner>(
workspace was not stopped."
.to_string()
})?;
let res = tokio::time::timeout(timeout, runner.run(vec!["--reason".into(), "stop".into()])).await;
let res =
tokio::time::timeout(timeout, runner.run(vec!["--reason".into(), "stop".into()], timeout)).await;
match res {
Err(_) => Err("Pushing unsaved work did not finish in time. The workspace was not stopped.".into()),
Ok(Err(e)) => Err(format!("Could not push unsaved work: {e}. The workspace was not stopped.")),
@@ -252,7 +276,7 @@ pub(crate) mod tests {
}
impl WipRunner for Fake {
async fn run(&self, args: Vec<String>) -> Result<(String, i64), String> {
async fn run(&self, args: Vec<String>, _limit: Duration) -> Result<(String, i64), String> {
self.calls.lock().unwrap().push(args);
if self.hang {
std::future::pending::<()>().await;
@@ -280,7 +304,7 @@ pub(crate) mod tests {
{\"folder\":\"web\",\"dirty\":false,\"unpushed\":2,\"branch\":\"HEAD\"}\n";
let repos = parse_dry_run(out).unwrap();
assert_eq!(repos.len(), 2);
assert_eq!(repos[0], RepoState { folder: "api".into(), dirty: true, unpushed: 0, branch: "main".into() });
assert_eq!(repos[0], RepoState { folder: "api".into(), dirty: true, unpushed: 0, branch: "main".into(), cloning: false });
assert_eq!(repos[1].unpushed, 2);
assert!(parse_dry_run("{\"folder\":1}").is_err());
assert!(parse_dry_run("").unwrap().is_empty());
@@ -346,6 +370,28 @@ pub(crate) mod tests {
assert!(fake.calls().is_empty());
}
#[test]
fn the_script_is_bounded_inside_the_workspace_before_the_client_gives_up() {
let cmd = wip_command(vec!["--reason".into(), "stop".into()], PUSH_TIMEOUT);
assert_eq!(cmd, vec!["/usr/bin/timeout", "-k", "5", "290", WIP_PUSH, "--reason", "stop"]);
let secs: u64 = cmd[3].parse().unwrap();
assert!(secs + 5 < PUSH_TIMEOUT.as_secs(), "TERM and KILL land before the client's timeout");
let dry = wip_command(vec!["--dry-run".into()], DRY_RUN_TIMEOUT);
assert_eq!(&dry[3..], &["20", WIP_PUSH, "--dry-run"]);
assert_eq!(wip_command(Vec::new(), Duration::from_secs(3))[3], "1", "never 0 (no limit)");
// The image has coreutils' timeout where the argv says.
let df = include_str!("../../../../container/Dockerfile");
assert!(df.contains("ubuntu:24.04") || df.contains("ubuntu:noble"), "coreutils at /usr/bin");
}
#[test]
fn a_cloning_repo_parses_with_its_flag_and_older_lines_without_it() {
let out = "{\"folder\":\"half\",\"dirty\":false,\"unpushed\":0,\"branch\":\"\",\"cloning\":true}\n\
{\"folder\":\"a\",\"dirty\":true,\"unpushed\":0,\"branch\":\"main\"}";
let repos = parse_dry_run(out).unwrap();
assert!(repos[0].cloning && !repos[1].cloning);
}
#[test]
fn long_output_keeps_its_end() {
let s = format!("{}END", "x".repeat(10_000));
+91 -1
View File
@@ -421,7 +421,8 @@ fn clone_repos_clones_skips_existing_and_reports_failures_without_failing() {
assert_eq!(fs::read_to_string(ws.join(".dotted/README")).unwrap(), "hello");
assert_eq!(
fs::read_to_string(status.join("repos")).unwrap(),
"main-copy\tcloned\ndev-copy\tcloned\nexisting\texists\nbroken\tfailed\n.dotted\tcloned\n"
"main-copy\tcloning\nmain-copy\tcloned\ndev-copy\tcloning\ndev-copy\tcloned\n\
existing\texists\nbroken\tcloning\nbroken\tfailed\n.dotted\tcloning\n.dotted\tcloned\n"
);
}
@@ -473,6 +474,11 @@ fn write_script(dir: &Path, name: &str, body: &str) -> std::path::PathBuf {
}
fn wip_push(ws: &Path, args: &[&str]) -> Output {
let tmp = ws.parent().unwrap();
wip_push_with_clone_dir(ws, args, &tmp.join("no-clone-status"))
}
fn wip_push_with_clone_dir(ws: &Path, args: &[&str], clone_dir: &Path) -> Output {
let tmp = ws.parent().unwrap();
let script = write_script(tmp, "triple-c-wip-push", WIP_PUSH);
// Through bash rather than exec'd: a script written a moment ago can be
@@ -481,6 +487,7 @@ fn wip_push(ws: &Path, args: &[&str]) -> Output {
.arg(script)
.args(args)
.env("TRIPLEC_WORKSPACE_ROOT", ws)
.env("TRIPLEC_CLONE_STATUS_DIR", clone_dir)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("HOME", tmp)
.output()
@@ -673,6 +680,44 @@ fn wip_push_uploads_lfs_objects_before_the_no_verify_push() {
assert_eq!(fs::read_to_string(&log).unwrap().trim(), format!("push origin {pushed}"));
}
#[test]
fn wip_push_skips_a_repo_the_background_clone_is_still_writing() {
let tmp = tempfile::tempdir().unwrap();
let (ws, repo, remote) = workspace_with_clone(tmp.path());
// `half` is mid-clone: a checkout in progress reads as a dirty tree.
git(&ws, &["clone", "-q", remote.to_str().unwrap(), "half"]);
fs::remove_file(ws.join("half/README")).unwrap();
fs::write(repo.join("y"), "y").unwrap();
let clone = tmp.path().join("clone");
fs::create_dir_all(&clone).unwrap();
fs::write(clone.join("repos"), "proj\tcloning\nproj\tcloned\nhalf\tcloning\n").unwrap();
let out = wip_push_with_clone_dir(&ws, &["--dry-run"], &clone);
assert!(out.status.success(), "{}", text(&out));
let stdout = String::from_utf8_lossy(&out.stdout);
let lines: Vec<serde_json::Value> = stdout.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
let by = |f: &str| lines.iter().find(|v| v["folder"] == f).unwrap().clone();
assert_eq!(
by("half"),
serde_json::json!({"folder": "half", "dirty": false, "unpushed": 0, "branch": "", "cloning": true})
);
assert_eq!(by("proj")["dirty"], true, "a finished clone is checked as usual");
// The Rust side parses that line.
let parsed = crate::commands::stop_guard::parse_dry_run(&stdout).unwrap();
assert!(parsed.iter().any(|r| r.folder == "half" && r.cloning));
let out = wip_push_with_clone_dir(&ws, &[], &clone);
assert!(out.status.success(), "{}", text(&out));
assert!(text(&out).contains("half: still cloning"), "{}", text(&out));
assert_eq!(wip_refs(&remote).len(), 1, "only proj was pushed");
// Once the clone is done, the folder is an ordinary repository again.
fs::write(clone.join("done"), "").unwrap();
let out = wip_push_with_clone_dir(&ws, &["--dry-run"], &clone);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains(r#"{"folder":"half","dirty":true"#), "{stdout}");
}
#[test]
fn wip_push_rejects_an_unknown_reason() {
let tmp = tempfile::tempdir().unwrap();
@@ -977,6 +1022,45 @@ fn idle_watch_pushes_before_an_idle_stop_and_stays_up_while_the_push_fails() {
assert_eq!(fs::read_to_string(w.tmp.path().join("reason")).unwrap().trim(), "idle");
}
#[test]
fn idle_watch_goes_back_to_watching_when_someone_comes_back_during_the_push() {
let w = Watch::new();
w.dirty();
let d = w.tmp.path();
// The push starts a process the watcher counts as `claude` (a renamed
// `sleep`, so `pgrep -x` matches it), as a user returning mid-push would.
let busy_name = format!("tcbusy{}", std::process::id() % 100_000);
let sleep = Command::new("sh").arg("-c").arg("command -v sleep").output().unwrap();
let sleep = String::from_utf8_lossy(&sleep.stdout).trim().to_string();
fs::copy(&sleep, d.join(&busy_name)).unwrap();
fs::set_permissions(d.join(&busy_name), fs::Permissions::from_mode(0o755)).unwrap();
write_script(
d,
"triple-c-wip-push",
&format!(
"#!/bin/bash\nif [ \"$1\" = --dry-run ]; then cat '{p}/dry'; exit 0; fi\n\
echo \"$*\" >> '{p}/pushes'\n'{p}/{busy_name}' 30 </dev/null >/dev/null 2>&1 &\n\
echo $! > '{p}/busy.pid'\nexit 0\n",
p = d.display()
),
);
let (url, mut agent) = fake_agent(d, OLD_ACTIVITY);
let mut target = fake_entrypoint(d);
let mut cmd = w.cmd(&url, target.id());
cmd.env("TRIPLEC_WIP_PUSH", "1").env("TRIPLEC_IDLE_PROC_NAME", &busy_name);
let ran = run_watch_for(cmd, 5);
if let Ok(pid) = fs::read_to_string(d.join("busy.pid")) {
let _ = Command::new("kill").arg(pid.trim()).status();
}
target.kill().ok();
target.wait().ok();
agent.kill().ok();
assert!(ran.is_none(), "it must not stop: {:?}", ran.map(|o| text(&o)));
assert_eq!(w.pushes(), "--reason idle\n", "it pushed once, then saw the activity");
assert!(!d.join("got").exists(), "no TERM");
assert!(!d.join("reason").exists(), "no idle marker");
}
#[test]
fn idle_watch_keeps_a_stop_guard_marker_already_recorded() {
for marker in ["pushed", "discard"] {
@@ -1138,6 +1222,12 @@ fn docker_mode_never_reaches_the_aws_blocks() {
};
let agent_call = lines.iter().position(|l| l.trim() == "start_agent").expect("start_agent call");
assert!(agent_call < pos("start_clone_repos"), "clones must start after the agent");
// The stop trap is in place before any AWS background work starts, so a
// TERM in between still runs aws_before_stop.
let trap = pos("trap stop_keepalive TERM INT HUP");
assert!(trap < pos("start_clone_repos"), "the trap must precede the clone");
assert!(trap < pos("start_idle_watch"), "the trap must precede the idle watcher");
assert!(pos("stop_keepalive()") < trap);
assert!(block(ENTRYPOINT, "aws_repos").contains(") &"), "start_clone_repos backgrounds the clone");
// And the snapshot-sensitive variables are gone before the agent's
// environment snapshot is taken.
+3 -1
View File
@@ -51,8 +51,10 @@ pub async fn poll_file(
"poll".to_string(),
container_path.to_string(),
];
// A background poll: an open viewer must not keep an idle AWS workspace
// up. Opening and saving a file are the user's own actions and count.
let (stdout, stderr, code) = container
.exec_oneshot_streams_as("claude", cmd, Vec::new())
.exec_oneshot_streams_background("claude", cmd)
.await?;
if code != 0 && code != 4 {
return Err(format!(
+11
View File
@@ -400,6 +400,17 @@ pub async fn exec_oneshot_streams_as(
Ok((out.stdout(), out.stderr(), out.exit_code))
}
/// [`exec_oneshot_streams_as`] marked as a background poll (the file
/// viewer's change check), so it never keeps an idle workspace up.
pub async fn exec_oneshot_streams_background(
ep: &AgentEndpoint,
user: &str,
cmd: Vec<String>,
) -> Result<(String, String, i64), String> {
let out = exec_oneshot_raw_marked(ep, user, cmd, Vec::new(), MAX_ONESHOT_OUTPUT, true).await?;
Ok((out.stdout(), out.stderr(), out.exit_code))
}
// ── detached execs, status, resize ───────────────────────────────────────────
#[derive(Deserialize)]
+16
View File
@@ -323,6 +323,22 @@ async fn only_a_background_poll_carries_the_background_marker() {
assert_eq!(seen.exec_bodies[0], seen.exec_bodies[1], "the request is otherwise the same");
}
#[tokio::test]
async fn the_file_viewers_poll_is_a_background_request_and_its_open_and_save_are_not() {
let (ep, fake) = fake_agent().await;
let t = target(&ep);
// The 2 s change check, as the viewer runs it.
let _ = crate::file_viewer::poll::poll_file(&t, "/workspace/x").await;
t.exec_oneshot_streams_background("claude", cmd("sh")).await.unwrap();
// What opening or saving goes through: user actions, which count.
t.exec_oneshot_streams_as("claude", cmd("sh"), Vec::new()).await.unwrap();
let seen = fake.seen.lock().unwrap();
assert_eq!(seen.exec_background, [Some("1".to_string()), Some("1".to_string()), None]);
let src = |s: &str| s.contains("exec_oneshot_streams_as(");
assert!(!src(include_str!("../../file_viewer/poll.rs")), "the poll stays a background request");
assert!(src(include_str!("../../file_viewer/resolve.rs")) && src(include_str!("../../file_viewer/write.rs")));
}
// ── one-shot execs ───────────────────────────────────────────────────────────
#[tokio::test]
+14 -1
View File
@@ -928,10 +928,23 @@ pub fn unattached_container_id(p: &Project) -> Option<String> {
if tunnels::get_endpoint(&p.id).is_some_and(|ep| ep.base_url.as_str() == stored) {
return None;
}
let placeholder = format!("http://unattached.invalid/{}/", p.id);
let placeholder = tunnels::unattached_id(&p.id);
(stored != placeholder).then_some(placeholder)
}
/// Before reconcile asks ECS about `p`: a project with no registered tunnel
/// is (or is about to be) known by its [`tunnels::unattached_id`], which a
/// past unattached spell may have left marked stopped. Reconcile is the
/// authority about the task now, so that mark goes; the watch re-marks it if
/// the task really is stopped. Returns the placeholder to store, if any.
pub fn prepare_unattached(p: &Project) -> Option<String> {
if !p.is_aws() || tunnels::get_endpoint(&p.id).is_some() {
return None;
}
tunnels::forget_stopped(&tunnels::unattached_id(&p.id));
unattached_container_id(p)
}
/// Reconcile one AWS project the store has as Running (or Starting/
/// Stopping/Error left by a crash): a gone task makes it Stopped (its task
/// ARN cleared); a live one keeps it Running and its ARN stored. An ECS
+20
View File
@@ -754,6 +754,26 @@ async fn reconcile_falls_back_to_a_newer_live_task_when_the_stored_one_stopped()
assert!(fake.called("list"), "it looked for another task first");
}
#[tokio::test]
async fn reconcile_forgets_a_stale_stop_on_the_unattached_id_before_reusing_it() {
use crate::host::tunnels;
let mut p = aws_project(StorageMode::Ephemeral);
p.status = ProjectStatus::Running;
p.aws.as_mut().unwrap().task_arn = Some(task_arn("u2"));
let placeholder = tunnels::unattached_id(&p.id);
// A past unattached spell's task was seen stopped under this id.
tunnels::mark_stopped(&placeholder, StopKind::Spot);
p.container_id = Some("http://127.0.0.1:61400/".into());
assert_eq!(prepare_unattached(&p), Some(placeholder.clone()));
assert!(!tunnels::is_stopped(&placeholder), "the live task must not read as stopped");
// Already on the placeholder: nothing to store, the mark still goes.
p.container_id = Some(placeholder.clone());
tunnels::mark_stopped(&placeholder, StopKind::Spot);
assert_eq!(prepare_unattached(&p), None);
assert!(!tunnels::is_stopped(&placeholder));
assert_eq!(prepare_unattached(&Project::new("l".into(), Vec::new())), None);
}
#[tokio::test]
async fn an_unattached_aws_project_gets_a_target_id_no_tunnel_can_have() {
// m9: the last run's loopback port may belong to another project's
+16
View File
@@ -302,6 +302,22 @@ impl ContainerTarget {
}
}
/// [`Self::exec_oneshot_streams_as`] for a background poll (the file
/// viewer's change check): on the agent it carries the background marker,
/// so it never counts as activity for idle stop. The same call on Docker.
pub async fn exec_oneshot_streams_background(
&self,
user: &str,
cmd: Vec<String>,
) -> Result<(String, String, i64), String> {
match self {
ContainerTarget::Docker { container_id } => {
docker::exec::exec_oneshot_streams_as(container_id, user, cmd, Vec::new()).await
}
ContainerTarget::Agent(ep) => agent::exec_oneshot_streams_background(ep, user, cmd).await,
}
}
/// Start a command and return without waiting or reading its output.
/// Returns the exec id.
pub async fn exec_detached(&self, spec: ExecSpec) -> Result<String, String> {
+39
View File
@@ -89,6 +89,21 @@ fn notify() {
changed().send_modify(|n| *n = n.wrapping_add(1));
}
/// The target id an AWS project has while no tunnel is registered for it
/// (see `aws_host::unattached_container_id`): never a loopback URL, so a
/// port a new tunnel reuses can't be confused with it.
pub fn unattached_id(project_id: &str) -> String {
format!("http://unattached.invalid/{project_id}/")
}
/// Forget that the watch saw the task behind `target_id` stopped. For a
/// project's [`unattached_id`], which (unlike an endpoint) is reused by
/// every later unattached spell of the same project: a stale entry would
/// make a later live task read as stopped.
pub fn forget_stopped(target_id: &str) {
lock().stopped.remove(target_id);
}
/// Register `tunnel` for `project_id`, returning the one it replaces (the
/// caller closes it).
pub fn register(project_id: &str, tunnel: LiveTunnel) -> Option<LiveTunnel> {
@@ -96,6 +111,7 @@ pub fn register(project_id: &str, tunnel: LiveTunnel) -> Option<LiveTunnel> {
let mut r = lock();
*r.generation.entry(project_id.to_string()).or_default() += 1;
r.stopped.remove(tunnel.endpoint.base_url.as_str());
r.stopped.remove(&unattached_id(project_id));
r.pending.remove(project_id);
r.live.insert(project_id.to_string(), tunnel)
};
@@ -165,6 +181,7 @@ pub fn register_if_current(
return Err(Box::new(tunnel));
}
r.stopped.remove(tunnel.endpoint.base_url.as_str());
r.stopped.remove(&unattached_id(project_id));
r.pending.remove(project_id);
r.live.insert(project_id.to_string(), tunnel)
};
@@ -358,6 +375,28 @@ pub(crate) mod tests {
assert_eq!(closes.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn a_registering_tunnel_clears_a_stop_seen_under_the_projects_unattached_id() {
let p = pid("unattached-stale");
let placeholder = unattached_id(&p);
mark_stopped(&placeholder, StopKind::Spot);
let closes = Arc::new(AtomicUsize::new(0));
register(&p, fake_tunnel(61301, closes.clone()));
assert!(!is_stopped(&placeholder), "a later unattached spell must not read as stopped");
close(&p).await;
// A re-attach registering clears it too; and it can be forgotten directly.
let q = pid("unattached-stale-reattach");
mark_stopped(&unattached_id(&q), StopKind::Spot);
let g = begin_reattach(&q);
assert!(register_if_current(&q, g, fake_tunnel(61302, closes.clone())).is_ok());
assert!(!is_stopped(&unattached_id(&q)));
close(&q).await;
mark_stopped(&unattached_id(&q), StopKind::Idle);
forget_stopped(&unattached_id(&q));
assert!(!is_stopped(&unattached_id(&q)));
}
#[tokio::test]
async fn a_reattach_that_began_before_a_stop_does_not_register() {
let closes = Arc::new(AtomicUsize::new(0));
+3
View File
@@ -727,6 +727,9 @@ pub struct RepoState {
pub dirty: bool,
pub unpushed: u64,
pub branch: String,
/// The start's background clone is still writing it: nothing to push.
#[serde(default)]
pub cloning: bool,
}
/// What `check_unsaved_work` found. `checked: false` (with `error`) when the
@@ -25,6 +25,17 @@ describe("StopGuardModal", () => {
expect(onChoose).toHaveBeenCalledWith("push");
});
it("lists a repository still cloning as having nothing to push", () => {
const work: UnsavedWork = {
...dirty,
repos: [...dirty.repos, { folder: "big", branch: "", dirty: false, unpushed: 0, cloning: true }],
};
render(<StopGuardModal projectName="api" work={work} onChoose={vi.fn()} />);
expect(screen.getByText("/workspace/big")).toBeInTheDocument();
expect(screen.getByText("still cloning — nothing to push")).toBeInTheDocument();
expect(screen.getByText("/workspace/api")).toBeInTheDocument();
});
it("offers Stop without pushing and Cancel", () => {
const onChoose = vi.fn();
render(<StopGuardModal projectName="api" work={dirty} onChoose={onChoose} />);
+10 -1
View File
@@ -31,7 +31,8 @@ function describeRepo(dirty: boolean, unpushed: number): string {
export default function StopGuardModal({ projectName, work, wipPushOnStop = false, onChoose }: Props) {
const pushRef = useRef<HTMLButtonElement>(null);
const stopRef = useRef<HTMLButtonElement>(null);
const atRisk = work.repos.filter((r) => r.dirty || r.unpushed > 0);
const atRisk = work.repos.filter((r) => !r.cloning && (r.dirty || r.unpushed > 0));
const cloning = work.repos.filter((r) => r.cloning);
return (
<Modal
@@ -75,6 +76,14 @@ export default function StopGuardModal({ projectName, work, wipPushOnStop = fals
</span>
</li>
))}
{cloning.map((r) => (
<li key={r.folder} className="text-[13px] text-[var(--text-primary)]">
<span className="font-mono">/workspace/{r.folder}</span>{" "}
<span className="text-[var(--text-secondary)]">
still cloning — nothing to push
</span>
</li>
))}
</ul>
<p className="mt-3 text-[13px] text-[var(--text-secondary)]">
Push to WIP branch saves a snapshot of each repository to a{" "}
@@ -165,6 +165,21 @@ describe("HostSection", () => {
await waitFor(() => expect(removeAwsResources).toHaveBeenCalledWith(p.id, true));
});
it("keeps a project whose kept files' access point remains on AWS until it is removed", () => {
const p = awsProject();
p.aws = { ...p.aws!, access_point_id: "fsap-1", task_arn: null, task_definition_arn: null };
renderHost(p);
const move = screen.getByRole("button", { name: "Run on this machine instead" });
expect(move).toHaveAttribute("aria-disabled", "true");
expect(move).toHaveAccessibleDescription(/access point is still on AWS/);
expect(screen.getByRole("button", { name: "Remove AWS resources…" })).not.toHaveAttribute(
"aria-disabled",
"true",
);
fireEvent.click(screen.getByRole("button", { name: "Remove AWS resources…" }));
expect(within(screen.getByRole("dialog")).getByText(/access point\s+stays too/)).toBeInTheDocument();
});
it("keeps the AWS resource buttons locked while the project is in Error (its task may live)", () => {
const p = awsProject({ status: "error" });
p.aws = { ...p.aws!, task_arn: "arn:task" };
@@ -58,6 +58,8 @@ export default function HostSection({ project, save, disabled }: Props) {
const saveAws = (patch: Partial<AwsWorkspaceConfig>) => save({ aws: { ...aws, ...patch } });
const hasResources = !!(aws.task_arn || aws.access_point_id || aws.task_definition_arn);
// A removal that kept the saved files keeps their access point stored.
const onlyAccessPoint = !!aws.access_point_id && !aws.task_arn && !aws.task_definition_arn;
// Not `disabled` (which also allows Error): on AWS, Error can mean a live task.
const stopped = project.status === "stopped";
@@ -343,7 +345,9 @@ export default function HostSection({ project, save, disabled }: Props) {
unavailableReason={
!stopped
? STILL_RUNNING
: "Remove the workspace's AWS resources first, so nothing is left running or billed."
: onlyAccessPoint
? "The saved files' access point is still on AWS. Remove the AWS resources with \u201cAlso remove the workspace\u2019s file-system access point\u201d first."
: "Remove the workspace's AWS resources first, so nothing is left running or billed."
}
onClick={() => void save({ host_id: null, aws_placement: null, aws: null })}
>
@@ -421,7 +425,9 @@ function RemoveAwsResourcesModal({ project, onClose }: { project: Project; onClo
Also remove the workspace&rsquo;s file-system access point
<span className="block text-xs text-[var(--text-secondary)]">
The files stay on your own EFS file system (only your workspace role can read
them) until an administrator offboards you.
them) until an administrator offboards you. Left unchecked, the access point
stays too: the next start here reuses it, and the project can&rsquo;t move to
another host until it is removed.
</span>
</span>
</label>
+2
View File
@@ -48,6 +48,8 @@ describe("awsWorkspace", () => {
expect(stopGuardNeeded({ checked: true, repos: [{ ...repo, dirty: true }], error: null })).toBe(true);
expect(stopGuardNeeded({ checked: true, repos: [{ ...repo, unpushed: 1 }], error: null })).toBe(true);
expect(stopGuardNeeded({ checked: false, repos: [], error: "x" })).toBe(true);
// A repository still cloning has nothing to push.
expect(stopGuardNeeded({ checked: true, repos: [{ ...repo, cloning: true }], error: null })).toBe(false);
});
it("selects only ready environments the user joined", () => {
+1 -1
View File
@@ -109,7 +109,7 @@ export function cleanRepos(rows: RepoSource[]): RepoSource[] {
/** Whether Stop must ask first (the one place this rule lives). */
export function stopGuardNeeded(work: UnsavedWork): boolean {
return !work.checked || work.repos.some((r) => r.dirty || r.unpushed > 0);
return !work.checked || work.repos.some((r) => !r.cloning && (r.dirty || r.unpushed > 0));
}
/** An environment a project can be placed in: ready (or merely behind) and joined. */
+2
View File
@@ -136,6 +136,8 @@ export interface RepoState {
dirty: boolean;
unpushed: number;
branch: string;
/** The start's background clone is still writing it: nothing to push. */
cloning?: boolean;
}
/** What `checkUnsavedWork` found (Rust `UnsavedWork`). `checked: false` (with
+18 -10
View File
@@ -635,9 +635,12 @@ chown claude:claude /home/claude/.gitconfig 2>/dev/null || true
# app gives a starting task's agent only seconds to answer, so a clone in the
# way of the agent would fail every start with a large repository. Terminals
# work while the repos arrive. Progress, for the app to show later, is in
# /run/triple-c-clone (world-readable, root-written): `repos` gets one
# "<folder>\t<cloned|failed|exists>" line per repo, and `done` appears when
# all are finished. The idle watcher counts an unfinished clone as activity.
# /run/triple-c-clone (world-readable, root-written): `repos` gets a
# "<folder>\tcloning" line as a clone begins and a
# "<folder>\t<cloned|failed|exists>" line when it ends (the last line for a
# folder is its state), and `done` appears when all are finished. The idle
# watcher counts an unfinished clone as activity; triple-c-wip-push skips a
# folder still `cloning`.
# >>> aws_repos
# clone_repos json root [status_dir]
clone_repos() {
@@ -658,6 +661,7 @@ clone_repos() {
[ -n "$branch" ] && args+=(--branch "$branch")
args+=(-- "$url" "$dest")
echo "entrypoint: cloning $folder..."
_clone_status "$folder" cloning
# BatchMode is appended to whatever ssh command the user's git config
# names (core.sshCommand), read as the user, rather than replacing it.
err=$(_triplec_as_user sh -c '
@@ -1244,6 +1248,17 @@ start_idle_watch() {
) &
}
# <<< aws_stop
# The stop trap goes in before any background work starts (the idle watcher
# can signal this script; a clone is work a stop must account for), so a TERM
# from here on always runs aws_before_stop. Defined after aws_stop, which
# holds the function it calls.
keepalive_pid=""
stop_keepalive() {
[ "$triplec_aws" = 1 ] && aws_before_stop
[ -n "$keepalive_pid" ] && kill "$keepalive_pid" 2>/dev/null
exit "${triplec_exit_status:-0}"
}
trap stop_keepalive TERM INT HUP
if [ "$triplec_aws" = 1 ] && [ -n "${TRIPLEC_IDLE_STOP_MINUTES:-}" ]; then
start_idle_watch "$TRIPLEC_IDLE_STOP_MINUTES" "$$"
fi
@@ -1253,13 +1268,6 @@ if [ "$triplec_aws" = 1 ]; then
fi
echo "Triple-C container ready."
keepalive_pid=""
stop_keepalive() {
[ "$triplec_aws" = 1 ] && aws_before_stop
[ -n "$keepalive_pid" ] && kill "$keepalive_pid" 2>/dev/null
exit "${triplec_exit_status:-0}"
}
trap stop_keepalive TERM INT HUP
throttle_fifo=/run/triple-c-keepalive.throttle
rm -f "$throttle_fifo"
mkfifo -m 600 "$throttle_fifo" 2>/dev/null && exec 3<>"$throttle_fifo"
+12 -3
View File
@@ -19,9 +19,10 @@
# * no work would be lost: `triple-c-wip-push --dry-run` (as claude) finds
# no dirty repository and no unpushed commit. With the WIP push on
# (TRIPLEC_WIP_PUSH=1) such work is pushed here, before the stop, and the
# workspace stops only once that push succeeded; a failed push keeps it
# running and is retried later (backing off). With it off, unsaved work
# simply keeps the workspace up.
# workspace stops only once that push succeeded and the workspace is
# still quiet afterwards (activity during the push sends it back to
# watching); a failed push keeps it running and is retried later (backing
# off). With it off, unsaved work simply keeps the workspace up.
#
# Anything it cannot read counts as NOT idle: a stop is the one thing here
# that cannot be undone, so every doubt keeps the workspace running.
@@ -206,6 +207,14 @@ while :; do
echo "triple-c-idle-watch: the WIP push failed — staying up; retrying in ${wait_s}s"
continue
fi
# The push took a while: someone may have come back meanwhile (a
# terminal, a `claude`, a scheduler job). Their new work would be
# lost, since the stop hook does not push again after an idle push.
if ! quiet; then
echo "triple-c-idle-watch: activity during the WIP push — not stopping"
said=""
continue
fi
fi
echo "triple-c-idle-watch: idle for ${minutes} minutes — stopping the workspace"
mkdir -p "$(dirname "$reason_file")" 2>/dev/null
+27 -1
View File
@@ -22,14 +22,22 @@
# {"folder":…, "dirty":bool, "unpushed":n, "branch":…}
# and changes nothing.
#
# A repository the start's background clone is still writing (the entrypoint's
# /run/triple-c-clone: its `repos` file's last line for the folder says
# `cloning` and `done` is absent) is skipped: a half-made checkout is not the
# user's work. The dry run reports it as
# {"folder":…, "dirty":false, "unpushed":0, "branch":"", "cloning":true}
#
# Exit 0 when every needed push succeeded (or nothing needed pushing), 1 when
# any failed, 2 on bad usage.
#
# TRIPLEC_WORKSPACE_ROOT overrides /workspace (tests).
# TRIPLEC_WORKSPACE_ROOT overrides /workspace, TRIPLEC_CLONE_STATUS_DIR
# /run/triple-c-clone (tests).
set -u
root="${TRIPLEC_WORKSPACE_ROOT:-/workspace}"
clone_dir="${TRIPLEC_CLONE_STATUS_DIR:-/run/triple-c-clone}"
dry_run=0
reason=stop
@@ -62,6 +70,14 @@ pick_remote() {
if [ "$(git remote | wc -l)" -eq 1 ]; then git remote; fi
}
# Whether the background clone is still writing folder $1: the clone is not
# `done` and the last state it recorded for the folder is `cloning`.
still_cloning() {
[ -d "$clone_dir" ] && [ ! -e "$clone_dir/done" ] || return 1
# ENVIRON, not -v: -v would read backslashes in a folder name as escapes.
[ "$(F="$1" awk -F '\t' '$1 == ENVIRON["F"] { s = $2 } END { print s }' "$clone_dir/repos" 2>/dev/null)" = cloning ]
}
user_ssh_command="${GIT_SSH_COMMAND:-}"
for dir in "$root"/*/; do
@@ -73,6 +89,16 @@ for dir in "$root"/*/; do
[ "$top" = "$(cd "$dir" && pwd -P)" ] || continue
folder=$(basename "$dir")
if still_cloning "$folder"; then
if [ "$dry_run" = 1 ]; then
jq -cn --arg folder "$folder" \
'{folder: $folder, dirty: false, unpushed: 0, branch: "", cloning: true}'
else
echo "triple-c-wip-push: $folder: still cloning — nothing to push"
fi
continue
fi
(
cd "$dir" || exit 1
# BatchMode: an unknown host key or a passphrase prompt fails at once