diff --git a/Partnership/partnership_onboard.sh b/Partnership/partnership_onboard.sh index 6064901..bb06d06 100755 --- a/Partnership/partnership_onboard.sh +++ b/Partnership/partnership_onboard.sh @@ -42,9 +42,10 @@ # would discover are actually deployed there # Step 12: Container grouping — file our containers on the mirror under "-Fallback", # icon resolved here and passed over: the mirror has no Emby key -# Step 13: Media seed — dispatch Rsync/media_seed.sh detached, after the phase flag -# is written. It is a multi-week transfer and must never be -# the thing standing between an onboard and its own summary +# +# Phase 3 (media seed) is NOT part of the above. Onboard ends at Phase 2 — connected, running, +# and saying so. Seeding is a separate operator-triggered phase; see --phase3-only. With +# MEDIA_SEED_ENABLED=false there is no Phase 3 and the model is two phases. # # ============================================================================================== # DESIGN PRINCIPLES @@ -159,14 +160,15 @@ # Partnership/partnership_onboard.sh --skip-webhook-setup # Skip webhook registration in arrs (Step 9b) # +# Partnership/partnership_onboard.sh --phase3-only +# OWNER: media seed only. Refuses unless Phase 2 is done and MEDIA_SEED_ENABLED is true. +# Sets the seeding gate posture (Tier 1 open, every Tier 2 gate closed) and dispatches +# Rsync/media_seed.sh detached. Returns in seconds; the seed runs for weeks. +# # Partnership/partnership_onboard.sh --skip-appdata-provision # Skip the pre-container auth appdata rsync (Step 1e) # The mirror's auth containers then start against whatever is already there # -# Partnership/partnership_onboard.sh --skip-media-seed -# Skip dispatching the background media seed (Step 13) -# Use when mirror already has files or you want to seed manually -# # Partnership/partnership_onboard.sh --skip-webhook-listener # Skip starting webhook listener on mirror (Step 9e) # Listener will start automatically on next array restart @@ -203,11 +205,11 @@ SKIP_ARR_STACK=false SKIP_SERVICES_STACK=false SKIP_ARR_SYNC=false SKIP_WEBHOOK_SETUP=false -SKIP_MEDIA_SEED=false SKIP_WEBHOOK_LISTENER=false PHASE1_ONLY=false # OWNER: SSH + conf push only (HOST2 not yet installed) # MIRROR: SSH key install only, no owner notification PHASE2_ONLY=false # OWNER: containers/arr/onboard only (triggered by HOST2 after it onboards) +PHASE3_ONLY=false # OWNER: media seed only — a separate decision, see the PHASE 3 block SKIP_ARM=false # leave the sync gates as they are — see Step 1d SKIP_APPDATA_PROVISION=false # skip the pre-container auth appdata sync — see Step 1e FILTERED_ARGS=() @@ -221,11 +223,11 @@ for arg in "$@"; do --skip-services-stack) SKIP_SERVICES_STACK=true ;; --skip-arr-sync) SKIP_ARR_SYNC=true ;; --skip-webhook-setup) SKIP_WEBHOOK_SETUP=true ;; - --skip-media-seed) SKIP_MEDIA_SEED=true ;; --skip-appdata-provision) SKIP_APPDATA_PROVISION=true ;; --skip-webhook-listener) SKIP_WEBHOOK_LISTENER=true ;; --phase1-only) PHASE1_ONLY=true ;; --phase2-only) PHASE2_ONLY=true; SKIP_SSH=true ;; + --phase3-only) PHASE3_ONLY=true; SKIP_SSH=true ;; --no-arm) SKIP_ARM=true ;; *) FILTERED_ARGS+=("$arg") ;; esac @@ -474,8 +476,124 @@ MIRROR_IP=$(resolve_tailscale_ip "$MIRROR") log "Mirror: $MIRROR ($MIRROR_IP)" [[ "$PHASE1_ONLY" == true ]] && log "Mode: Phase 1 only (SSH + conf push)" [[ "$PHASE2_ONLY" == true ]] && log "Mode: Phase 2 only (containers + arr + onboard)" +[[ "$PHASE3_ONLY" == true ]] && log "Mode: Phase 3 only (media seed)" echo "" +# ============================================================================================== +# ── PHASE 3: MEDIA SEED ─────────────────────────────────────────────────────────────────────── +# +# The partnership is finished at the end of Phase 2 — connected, running, and saying so. Phase 3 +# is not part of it. It is the separate decision to fill the partner's disks by rsync, and it is +# operator-triggered because a first seed of a full library is weeks of transfer and tens of +# terabytes of somebody else's free space. +# +# The seed used to be a step inside Phase 2. Inline it held the onboard open for the entire +# transfer, which meant the phase-2 flag, the conf push, discovery and container grouping all +# waited on it, and both hosts described an unfinished onboard over a partnership that was +# already live. Detaching it fixed the blocking but left the seed starting on its own, which is +# still the wrong default: the machine decided to move 28 TB because an onboard succeeded. +# +# With MEDIA_SEED_ENABLED=false there is no Phase 3 at all and the model is two phases. That is +# the point of the toggle — not a seed that runs and does nothing, an absent phase. +# ============================================================================================== +if [[ "$PHASE3_ONLY" == true ]]; then + echo "" + echo "━━━━━ $ICON_SYNC PHASE 3 — MEDIA SEED — $MY_ID → $MIRROR ━━━━━" + echo "" + + _master_conf="$SCRIPTS_ROOT/Configurations/master.conf" + _seed_gate=$(grep -m1 -E '^[[:space:]]*MEDIA_SEED_ENABLED=' "$_master_conf" 2>/dev/null \ + | cut -d= -f2- | cut -d'#' -f1 | tr -d '"'"'" | tr -d '[:space:]') + + if [[ -n "$_seed_gate" && "$_seed_gate" != "true" ]]; then + error "MEDIA_SEED_ENABLED is '$_seed_gate' — there is no Phase 3 on this host" + error " Onboarding is two phases while seeding is off. Arm the toggle in master.conf" + error " if you want the partner's library filled by rsync." + exit 1 + fi + + _setup_db="$(platform_setup_db_path)" + if ! grep -q "^${MIRROR_ID}_PHASE2_DONE=true" "$_setup_db" 2>/dev/null; then + error "$MIRROR has not completed Phase 2 — seeding a partner that is not onboarded" + error " would push media at a host with no containers to serve it. Finish Phase 2 first." + exit 1 + fi + unset _setup_db + + # Seeding posture, set before the transfer rather than after it. Tier 1 has to be open or + # rsync.sh refuses every share; every Tier 2 gate is closed so the scheduled orchestrators + # are not competing for the same link and the same disks for the next several weeks. This is + # also the posture the seed leaves behind — global on, per-orchestrator off — so finishing + # Phase 3 does not quietly hand the schedule back. + echo "━━━ Gate posture ━━━" + if [[ "$DRY_RUN" == true ]]; then + warn "DRY RUN — would set RSYNC_ENABLED true and every Tier 2 rsync gate false" + else + cp -a "$_master_conf" "${_master_conf}.bak-phase3-$(date +%Y%m%d-%H%M%S)" + set_conf_bool RSYNC_ENABLED "true" "$_master_conf" \ + || { error "Could not open Tier 1 — the seed would move nothing"; exit 1; } + for _gate in CRITICAL_RSYNC_ENABLED INTERMEDIATE_RSYNC_ENABLED DAILY_RSYNC_ENABLED \ + WEEKLY_RSYNC_ENABLED MONTHLY_RSYNC_ENABLED FALLBACK_RSYNC_ENABLED; do + set_conf_bool "$_gate" "false" "$_master_conf" \ + || warn "Could not close $_gate — a scheduled sync may run beside the seed" + done + unset _gate + echo " Tier 1 open, every Tier 2 gate closed ✅" + fi + unset _master_conf _seed_gate + + echo "" + echo "━━━ Dispatch ━━━" + _seed_script="$SCRIPTS_ROOT/Rsync/media_seed.sh" + _runner="$SCRIPTS_ROOT/Plugin/$PLATFORM/run_job.sh" + if [[ "$DRY_RUN" == true ]]; then + warn "DRY RUN — would dispatch Rsync/media_seed.sh" + exit 0 + fi + if [[ ! -f "$_seed_script" || ! -f "$_runner" ]]; then + error "Seed runner not found at $_seed_script" + exit 1 + fi + + # setsid, not nohup + &: the seed must outlive this script and must not share its process + # group, or stopping Phase 3 takes the transfer down with it. + _seed_flags=() + [[ "$ENABLE_LOGGING" == true ]] && _seed_flags+=(--log) + setsid bash "$_runner" "Rsync/media_seed.sh" "$_seed_script" "${_seed_flags[@]}" \ + >/dev/null 2>&1 < /dev/null & + disown 2>/dev/null || true + + # Report the record, not the launch. run_job.sh writes its stat file before running the + # script, so a fresh "running" record is the difference between dispatched and attempted. + _seed_stat="/var/log/varaverk/Rsync/media_seed.json" + _dispatched=false + for _i in $(seq 1 10); do + if [[ -f "$_seed_stat" ]] \ + && grep -q '"status":"running"' "$_seed_stat" 2>/dev/null \ + && (( $(date +%s) - $(stat -c %Y "$_seed_stat") < 60 )); then + _dispatched=true + break + fi + sleep 1 + done + + echo "" + echo "━━━━━ $ICON_SUMMARY PHASE 3 SUMMARY ━━━━━" + if [[ "$_dispatched" == true ]]; then + echo " Seed: running as job Rsync/media_seed.sh ✅" + echo " Shares: ${#DAILY_SYNC_SHARES[@]}" + echo " Gates: Tier 1 open · every Tier 2 closed" + echo "" + echo " A first full seed runs for days. Watch it on the Partnership tab; the" + echo " partnership itself has been live since Phase 2 and does not depend on this." + echo "━━━━━━━━━━━━━━━━━━━━━━━" + exit 0 + fi + error " Seed did not start — check /var/log/varaverk/Rsync/media_seed.log" + echo "━━━━━━━━━━━━━━━━━━━━━━━" + exit 1 +fi + STEP_SSH_OK=false STEP_NETWORK_OK=false LOCAL_SETUP_OK=true # partnership_manager --local-only; the summary claimed done ✅ regardless @@ -1122,78 +1240,6 @@ fi # ── Write Phase 2 completion state ──────────────────────────────────────────────────────────── [[ "$ONBOARD_OK" == true && "$DRY_RUN" == false ]] && write_onboard_phase "$MIRROR_ID" 2 -# ── Step 13: Dispatch the media seed ────────────────────────────────────────────────────────── -# Last, detached, and after the phase flag is already on disk — see the header of -# Rsync/media_seed.sh for why. Briefly: on HOST1 this is thirteen shares and ~28 TB behind a -# 12.5 MB/s bwlimit, so inline it held this script open for weeks. Everything below it waited, -# including write_onboard_phase above, so the owner's card stayed on "Waiting for HOST2 to -# install Varaverk" and the mirror's checklist on "1 required item left: Partnership" for the -# entire transfer, describing a partnership that was already established. The onboard job -# record also stayed "running", which run_job.sh's already-running guard reads as a reason to -# refuse every later onboard. -# -# setsid, not nohup + &: the seed must survive this script exiting and must not share a process -# group with it, or stopping the onboard takes the transfer down with it. -echo "" -echo "━━━ $ICON_SYNC Step 13 — Media Share Seed (dispatch) ━━━" - -MEDIA_SEED_DISPATCHED=false -_seed_job="Rsync/media_seed.sh" -_seed_script="$SCRIPTS_ROOT/Rsync/media_seed.sh" -_runner="$SCRIPTS_ROOT/Plugin/$PLATFORM/run_job.sh" - -# Read from disk, not from the sourced value: Step 1d rewrote master.conf a few steps ago. -# Unset reads as on — the toggle postdates the seed, so a conf that has not been through a -# conf_upgrade must keep the behaviour it had. media_seed.sh checks this again itself; the -# check here exists so the summary can say "disabled" instead of dispatching a job whose only -# act is to exit. -_seed_gate=$(grep -m1 -E '^[[:space:]]*MEDIA_SEED_ENABLED=' "$SCRIPTS_ROOT/Configurations/master.conf" 2>/dev/null \ - | cut -d= -f2- | cut -d'#' -f1 | tr -d '"'"'" | tr -d '[:space:]') - -if [[ "$SKIP_MEDIA_SEED" == true ]]; then - warn "Skipping (--skip-media-seed)" -elif [[ -n "$_seed_gate" && "$_seed_gate" != "true" ]]; then - warn "MEDIA_SEED_ENABLED is '$_seed_gate' — not dispatching the seed" - warn "$MIRROR will need its library filled another way, or arm the toggle and start it" - warn "from the Partnership tab" -elif [[ "$ONBOARD_OK" == false ]]; then - warn "Skipping — onboard did not complete" -elif [[ "$DRY_RUN" == true ]]; then - warn "DRY RUN — would dispatch $_seed_job" -elif [[ ! -f "$_seed_script" || ! -f "$_runner" ]]; then - warn "Seed runner not found — run manually: bash $_seed_script" -else - _seed_flags=() - [[ "$ENABLE_LOGGING" == true ]] && _seed_flags+=(--log) - setsid bash "$_runner" "$_seed_job" "$_seed_script" "${_seed_flags[@]}" \ - >/dev/null 2>&1 < /dev/null & - disown 2>/dev/null || true - - # Confirm the runner actually took the job rather than reporting the launch. run_job.sh - # writes its stat file before exec'ing the script, so a fresh "running" record with a live - # pid is the difference between dispatched and merely attempted. - _seed_stat="/var/log/varaverk/Rsync/media_seed.json" - for _i in 1 2 3 4 5 6 7 8 9 10; do - if [[ -f "$_seed_stat" ]] \ - && grep -q '"status":"running"' "$_seed_stat" 2>/dev/null \ - && (( $(date +%s) - $(stat -c %Y "$_seed_stat") < 60 )); then - MEDIA_SEED_DISPATCHED=true - break - fi - sleep 1 - done - unset _seed_stat _i _seed_flags - - if [[ "$MEDIA_SEED_DISPATCHED" == true ]]; then - echo "Seed running as job $_seed_job ✅" - echo " ${#DAILY_SYNC_SHARES[@]} share(s) — watch it on the Scheduler tab, not here" - echo " A first full seed runs for days. The partnership is live now; this only fills disks." - else - warn "Seed did not start — run manually: bash $_seed_script" - fi -fi -unset _seed_job _seed_script _runner - # ── Summary ─────────────────────────────────────────────────────────────────────────────────── END=$(date +%s) echo "" @@ -1223,10 +1269,6 @@ echo " Step 9e — Webhook listener: $(_skip "$SKIP_WEBHOOK_LISTENER" "$WEBHOOK echo " Step 10 — Conf push: $( [[ "$ONBOARD_OK" == false ]] && echo "skipped" || echo "$(_ok "$MASTER_PUSH_OK")" )" echo " Step 11 — Discovery: $( [[ "$POPULATE_OK" == skipped ]] && echo "skipped (unreachable)" || _ok "$POPULATE_OK" )" echo " Step 12 — Grouping: $( [[ "$FOLDER_OK" == skipped ]] && echo "skipped" || _ok "$FOLDER_OK" )" -echo " Step 13 — Media seed: $( [[ "$SKIP_MEDIA_SEED" == true ]] && echo "skipped" \ - || { [[ -n "${_seed_gate:-}" && "${_seed_gate:-}" != "true" ]] && echo "off (MEDIA_SEED_ENABLED=$_seed_gate)" \ - || { [[ "$MEDIA_SEED_DISPATCHED" == true ]] && echo "dispatched — runs in background ✅" \ - || echo "not dispatched ❌"; }; } )" echo "" if [[ "$ONBOARD_OK" == true ]]; then diff --git a/Plugin/unraid/include/partnership.php b/Plugin/unraid/include/partnership.php index 54a1768..a8172fc 100644 --- a/Plugin/unraid/include/partnership.php +++ b/Plugin/unraid/include/partnership.php @@ -242,7 +242,7 @@ function vv_pt_remote_system(string $ip, string $sshKey): array { // // Returns [] when there has never been a seed. A stale "running" record whose pid is gone is // reported as stopped rather than running: a record is not a process. -function vv_pt_media_seed(): array { +function vv_pt_media_seed(array $setupDb = [], string $nodeIdUpper = ''): array { $stat = '/var/log/varaverk/Rsync/media_seed.json'; $log = '/var/log/varaverk/Rsync/media_seed.log'; @@ -257,10 +257,16 @@ function vv_pt_media_seed(): array { $toggle = vv_arr_scalar($raw, 'MEDIA_SEED_ENABLED'); $enabled = ($toggle === '' || strtolower($toggle) === 'true'); - if (!is_file($stat)) return ['enabled' => $enabled]; + // Phase 3 is complete only when every share landed, which media_seed.sh decides and records + // at the end of a run that may have taken weeks. The job record cannot answer this: it goes + // "ok" the moment the process exits, including the exits that seeded nothing. + $phase3Done = $nodeIdUpper !== '' + && ($setupDb[$nodeIdUpper . '_PHASE3_DONE'] ?? '') === 'true'; + + if (!is_file($stat)) return ['enabled' => $enabled, 'phase3_done' => $phase3Done]; $j = json_decode((string)file_get_contents($stat), true); - if (!is_array($j)) return ['enabled' => $enabled]; + if (!is_array($j)) return ['enabled' => $enabled, 'phase3_done' => $phase3Done]; $status = (string)($j['status'] ?? ''); $pid = (int)($j['pid'] ?? 0); @@ -292,7 +298,8 @@ function vv_pt_media_seed(): array { } return array_filter([ - 'enabled' => $enabled, + 'enabled' => $enabled, + 'phase3_done' => $phase3Done, 'status' => $status, 'start' => isset($j['start']) ? (int)$j['start'] : null, 'end' => isset($j['end']) ? (int)$j['end'] : null, @@ -433,7 +440,7 @@ function vv_pt_nodes(): array { 'api_key_set' => $apiKeySet, 'api_key_preview' => $apiKeyPreview, 'metrics' => $metrics, - 'media_seed' => $isMe ? [] : vv_pt_media_seed(), + 'media_seed' => $isMe ? [] : vv_pt_media_seed($setupDb, $nodeIdUpper), ]; } return $nodes; diff --git a/Plugin/unraid/pages/partnership.php b/Plugin/unraid/pages/partnership.php index fb90586..cd83be6 100644 --- a/Plugin/unraid/pages/partnership.php +++ b/Plugin/unraid/pages/partnership.php @@ -381,27 +381,23 @@ function vvPtHostChanged(el) { // Stop the background media seed. Safe to press: rsync.sh runs --inplace --partial, so this // costs the file in flight and a later start resumes the share rather than restarting it. -// Start the background seed. Nothing here decides whether it may run — MEDIA_SEED_ENABLED is -// checked by the script, and the button is not rendered when the toggle is off. -async function vvPtStartSeed(btn) { - if (!await vvConfirm('Start the media seed?\n\nEvery DAILY_SYNC_SHARES entry is pushed to the partner. A first seed of a full library runs for days.')) return; - btn.disabled = true; btn.textContent = '⟳'; - try { - const r = await fetch('/plugins/varaverk/api/media_seed.php', { - method: 'POST', - headers: {'Content-Type': 'application/x-www-form-urlencoded'}, - body: new URLSearchParams({ - csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '', - action: 'start' - }) - }); - const d = await r.json(); - if (!d.ok) { vvAlert('Seed did not start: ' + (d.error ?? 'Unknown error')); btn.disabled = false; btn.textContent = '▶ Seed'; return; } - if (typeof vvPtLoad === 'function') vvPtLoad(); - } catch (e) { - btn.disabled = false; btn.textContent = '▶ Seed'; - vvAlert('Error: ' + e); - } +// Phase 3 — the media seed, as its own phase rather than the tail of an onboard. Routed through +// partnership_onboard.sh --phase3-only rather than straight at media_seed.sh, because the phase +// is more than the transfer: it refuses when Phase 2 is unfinished, and it sets the gate posture +// the seed needs (Tier 1 open so rsync.sh will move anything, every Tier 2 gate closed so the +// scheduled orchestrators are not fighting it for the link for the next several weeks). +async function vvPtPhase3(btn, id) { + const label = btn.textContent; + if (!await vvConfirm(`Start Phase 3 — seed media to ${id}?\n\nEvery DAILY_SYNC_SHARES entry is pushed. A first seed of a full library runs for days, and the partnership is already live without it.\n\nTier 1 rsync is opened and every per-orchestrator gate is closed for the duration.`)) return; + btn.disabled = true; + btn.textContent = '⟳ Starting…'; + _vvPtRun('Partnership/partnership_onboard.sh', '--phase3-only') + .then(d => { if (!d.ok) vvAlert('Phase 3 did not start: ' + (d.error ?? 'Unknown error')); }) + .catch(e => vvAlert('Error: ' + e)) + .finally(() => setTimeout(() => { + btn.disabled = false; btn.textContent = label; + if (typeof vvPtLoad === 'function') vvPtLoad(); + }, 3000)); } async function vvPtStopSeed(btn) { @@ -1178,35 +1174,41 @@ function _renderActions(nodes, cfg) { // MEDIA_SEED_ENABLED is false, so the record can read "ok" for a seed that never // moved a byte — showing that as "Seed complete" would be exactly the kind of // outcome-from-a-toggle claim this codebase keeps having to unpick. + // Phase 3 — the media seed. Deliberately not rendered at all when MEDIA_SEED_ENABLED is + // false: with seeding off the model is two phases, and a greyed-out third one would + // invite the question of how to reach it. The toggle removes the phase, it does not + // disable a button. + // + // The toggle is also read before the job record. media_seed.sh exits 0 when seeding is + // off, so the record can read "ok" for a run that moved nothing. const ms = remote.media_seed || {}; - const seedRunning = ms.status === 'running'; - let seedColor, seedLabel; - if (ms.enabled === false && !seedRunning) { - seedColor = '#555'; seedLabel = 'Seed off'; - } else if (seedRunning) { seedColor = '#ff9800'; seedLabel = 'Seeding'; } - else if (ms.status === 'ok') { seedColor = '#4caf50'; seedLabel = 'Seed complete'; } - else if (ms.status === 'stopped'){ seedColor = '#666'; seedLabel = 'Seed stopped'; } - else if (ms.status) { seedColor = '#f44336'; seedLabel = 'Seed failed'; } - else { seedColor = '#555'; seedLabel = 'Not seeded'; } + if (ms.enabled !== false) { + const seedRunning = ms.status === 'running'; + const seedDone = ms.phase3_done === true; + let seedColor, seedLabel; + if (seedRunning) { seedColor = '#ff9800'; seedLabel = 'Phase 3 — seeding'; } + else if (seedDone) { seedColor = '#4caf50'; seedLabel = 'Phase 3 complete'; } + else if (ms.status === 'stopped') { seedColor = '#666'; seedLabel = 'Phase 3 stopped'; } + else if (ms.status === 'ok') { seedColor = '#ff9800'; seedLabel = 'Phase 3 partial'; } + else if (ms.status) { seedColor = '#f44336'; seedLabel = 'Phase 3 failed'; } + else { seedColor = '#555'; seedLabel = 'Phase 3 not started'; } - const seedOff = ms.enabled === false; - const showLine = seedRunning || (ms.line && !seedOff); - html += `
-
- ${seedLabel} - ${seedOff ? `MEDIA_SEED_ENABLED=false` : ''} - ${!seedOff && ms.share ? `${vvEscHtml(ms.share)}` : ''} - ${seedRunning - ? `` - : (seedOff ? '' : ``)} -
- ${showLine ? `
${vvEscHtml(ms.line || '')}
` : ''} -
`; + html += `
+
+ ${seedLabel} + ${ms.share ? `${vvEscHtml(ms.share)}` : ''} + ${seedRunning + ? `` + : ``} +
+ ${ms.line ? `
${vvEscHtml(ms.line)}
` : ''} +
`; + } } // ── Delete Keys panel ────────────────────────────────────────────────── diff --git a/Rsync/media_seed.sh b/Rsync/media_seed.sh index 1864f41..c0cce07 100755 --- a/Rsync/media_seed.sh +++ b/Rsync/media_seed.sh @@ -174,5 +174,22 @@ if [[ "$SEEDED" -eq 0 ]]; then error "No shares seeded" exit 1 fi -[[ "${#FAILED_SHARES[@]}" -gt 0 ]] && exit 1 +if [[ "${#FAILED_SHARES[@]}" -gt 0 ]]; then + warn "Phase 3 incomplete — the flag stays unset while any share is unseeded" + exit 1 +fi + +# Phase 3 is complete only when every share landed. This is what the Partnership tab reads to +# stop offering ▶ Phase 3, so writing it on a partial run would retire the button over a partner +# still missing shares — and a partial seed is the normal outcome of the first attempt, because +# a disk unmounted on the far side fails its share and no other. +# +# Written here rather than by the dispatcher: the dispatcher returns in seconds and knows only +# that the job started. Weeks separate those two facts. +if [[ -n "${REMOTE_ID:-}" ]]; then + set_state_var "$(platform_setup_db_path)" "${REMOTE_ID}_PHASE3_DONE" "true" + platform_push_setup_state >/dev/null 2>&1 \ + || warn "Phase 3 flag written locally but not pushed to the partner" + echo "Phase 3 complete — ${REMOTE_ID}_PHASE3_DONE=true ✅" +fi exit 0