row 180 (#1678, P19 391): activation ids within one second and plan-mode refusals

next_activation() takes the UTC second once and tries the base id, then -1 through -9, against
stack-rollback/, backups/stack/ and the post-attempt store, all under the lock. ACT and POST share
the suffix, and with all ten taken it refuses capture-failed with nothing stopped. Adopt uses the
same helper. --restore takes the old and the suffixed form.

Under --plan, a bad --tester-port reports tester-port-invalid through check_refuse and
tester-port-target goes through check_refuse in both modes, so the plan lists every refusal.
Without --plan the output is the usage block, byte for byte as before. webui-service.sh counts the
tester port's digits as written before stripping zeros, so 007333 refuses as serve does.

Round 2 adds subtests C1 (adopt takes the next free id) and C2 (a bad port is usage before any
pointer read) for Dewey's round 1 (#1678 comment 29352). Dewey approved round 2 in comment 29361.
This commit is contained in:
2026-10-19 22:53:14 -05:00
committed by jason.woltje
parent 828d14a0c9
commit 1905e385c4
5 changed files with 161 additions and 10 deletions
+14 -3
View File
@@ -387,6 +387,12 @@ one final line with `result` `ok`, `refused`, `gate-failed`,
failure that rolled back exits 1, a failed rollback exits 2, usage exits 4. Only one run at a time holds
`state/stack-release.lock` (`run-locked`).
An activation id is the UTC second and the target copy's first 12 characters,
`20261020T013708Z-89af38a83afa`. A second run in the same second adds `-1`,
then `-2` up to `-9`, the first one with no rollback set, backup or
post-attempt store yet, so ids still sort by time. With all ten taken the run
refuses `capture-failed` before it stops anything. Adopt uses the same rule.
1. Once per host, `adopt BUSINESS` records the running stack as the pointer:
the copy `stack-copy.sh check` reports, the bus unit's `--pm` and
`MOSAIC_CLAUDE_BIN`, the Console's flags without `--checkout`, and the
@@ -421,11 +427,13 @@ failure that rolled back exits 1, a failed rollback exits 2, usage exits 4. Only
this start. A pass writes the pointer.
6. A gate failure puts back the saved units and wrapper, restores the backup
when a migration ran, keeps the store it replaced as
`bus.post-attempt-<stamp>`, starts the old copy and gates it. If that gate
`bus.post-attempt-<stamp>`, where the stamp is the activation's second and
its suffix if it has one, starts the old copy and gates it. If that gate
fails too, both units stay stopped, the log says `rollback-failed`, and
Jason or Mos settles it from the rollback set the message names.
7. `rollback BUSINESS` returns to the copy before the pointer's, taken from
the log. When the schemas differ it needs `--restore ACTIVATION`; events
the log. When the schemas differ it needs `--restore ACTIVATION`, with or
without a `-1` to `-9` suffix; events
written after that backup need `--events N` with the count it prints, and
Jason decides first. It refuses `business-unreadable` and
`unknown-kind-for-target` like `activate`. For the second, set each kind
@@ -834,7 +842,10 @@ unit write for the authorized operator, with no start/restart verb.
`stack-release.sh activate ID BUSINESS --tester-port N` replaces or adds this
Console flag in both the installed unit and the pointer. It checks the target
copy's own Console before stopping anything; an explicit unsupported request
refuses `tester-port-target`. Without an explicit flag, activate and rollback
refuses `tester-port-target`. A port outside 1024 to 65535, 7332 or the
Console's `--port` is a usage error (exit 4). Under `--plan` both are listed
with the plan's other refusals instead, the bad port as `tester-port-invalid`,
and the plan exits 3. Without an explicit flag, activate and rollback
keep it between supporting copies, or drop it when the target predates support.
The plan prints the final Console flags and any dropped `--tester-port N`;
the steps log records a drop too. `pm-on` keeps the pointer's Console flags.
+102
View File
@@ -1095,3 +1095,105 @@ test('T42 tester-port pm-on preserves pointer Console flags',async(t)=>{
r=await run(e,'pm-on','acme');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,testerFlags(e));assert.match(consoleText(e),/--tester-port 7333(?:\n|$)/);
});
// Row 180 item 1 (P19 391): a date stand-in prints one second for every call that names a time,
// so each run below starts in that second, as two runs within one real second do. Calls for
// +%s (the gate's start mark) pass through, so each gate still reads only its own start.
function pinClock(e){
const dir=join(e.root,'clock'),second=Math.floor(Date.now()/1000);mkdirSync(dir);
writeFileSync(join(dir,'date'),`#!/bin/sh\ncase "$*" in *%s*) exec /usr/bin/date "$@" ;; esac\nexec /usr/bin/date -d @${second} "$@"\n`,{mode:0o755});
e.env={...e.env,PATH:`${dir}:${e.env.PATH}`};
return execFileSync(join(dir,'date'),['-u','+%Y%m%dT%H%M%SZ'],{env:e.env,encoding:'utf8'}).trim();
}
const rollbackSets=(e)=>readdirSync(join(e.state,'stack-rollback')).sort();
test('T43 row 180 item 1: runs in one second get distinct activation ids, rollback sets and post-attempt stores',async(t)=>{
await t.test('activate then pm-on on the same copy',async(t)=>{
const e=await mk(t);await seed(e,{pm:true});const sec=pinClock(e),id=`${sec}-${C1.slice(0,12)}`;
let r=await run(e,'activate',C1,'acme','--pm-after-harness');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(e).activation,id);
biz(e,HARNESS);
r=await run(e,'pm-on','acme');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(e).activation,`${id}-1`);
assert.deepEqual(logLines(e).slice(-4).map((l)=>[l.op,l.result,l.activation]),[['activate','started',id],['activate','ok',id],['pm-on','started',`${id}-1`],['pm-on','ok',`${id}-1`]]);
assert.deepEqual(rollbackSets(e),[id,`${id}-1`]);
for(const a of [id,`${id}-1`])assert.ok(existsSync(join(e.state,'stack-rollback',a,'SHA256SUMS')),a);
});
await t.test('two restores: an old-form id and a suffixed one, each with its own post-attempt store',async(t)=>{
const e=await mk(t);await seed(e);const sec=pinClock(e),c=`${sec}-${C.slice(0,12)}`,b=`${sec}-${B.slice(0,12)}`;
for(const [act,restore] of [[c,b],[`${c}-1`,`${b}-1`]]){
let r=await run(e,'activate',C,'acme','--migrate');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).activation,act);
assert.ok(existsSync(join(e.data,'backups','stack',act,'SHA256SUMS')),act);
r=await run(e,'rollback','acme','--restore',act);assert.equal(r.code,0,r.out+r.err);
const post=join(e.data,`bus.post-attempt-${sec}${restore.slice(b.length)}`);
assert.ok(r.out.startsWith(`restore: ${join(e.data,'backups','stack',act)}; the store now in place moves to ${post} and is kept; 0 event(s) were written after the backup\n`),r.out);
assert.deepEqual([pointer(e).copy,pointer(e).activation,pointer(e).schema],[B,restore,'3g']);
assert.equal(logLines(e).at(-1).backup,act);
}
assert.deepEqual(posts(e).sort(),[`bus.post-attempt-${sec}`,`bus.post-attempt-${sec}-1`]);
assert.deepEqual(readdirSync(join(e.data,'backups','stack')).sort(),[c,`${c}-1`]);
assert.deepEqual(rollbackSets(e),[c,`${c}-1`,b,`${b}-1`].sort());
});
await t.test('adopt takes the next free id too',async(t)=>{
const e=await mk(t);await seed(e,{pointer:'none'});const id=`${pinClock(e)}-${B.slice(0,12)}`;
mkdirSync(join(e.state,'stack-rollback',id),{recursive:true});
const r=await run(e,'adopt','acme');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(e).activation,`${id}-1`);
assert.deepEqual(logLines(e).map((l)=>[l.op,l.result,l.activation]),[['adopt','ok',`${id}-1`]]);
});
await t.test('all ten names taken: capture-failed before anything stops, and the log line names no activation',async(t)=>{
const e=await mk(t);await seed(e);const sec=pinClock(e),id=`${sec}-${C1.slice(0,12)}`;
// A rollback set, a backup or a post-attempt store each takes a name.
for(const s of ['','-1','-2','-3','-4','-5','-6','-7'])mkdirSync(join(e.state,'stack-rollback',`${id}${s}`),{recursive:true});
mkdirSync(join(e.data,'backups','stack',`${id}-8`),{recursive:true});writeFileSync(join(e.data,`bus.post-attempt-${sec}-9`),'');
await refused(e,['activate',C1,'acme'],'capture-failed',`every activation id for ${id} is taken; nothing was stopped`);
assert.equal(logLines(e).at(-1).activation,null);
});
await t.test('--restore takes an id with or without -1 to -9 and nothing else',async(t)=>{
const e=await mk(t);await seed(e);const id=`20261020T013708Z-${B.slice(0,12)}`;
for(const bad of [`${id}-0`,`${id}-10`,`${id}-a`,`${id}-`,`${id}1`]){
const r=await run(e,'rollback','acme','--restore',bad,'--plan');assert.equal(r.code,4,bad);
}
for(const good of [id,`${id}-1`,`${id}-9`]){
const r=await run(e,'rollback','acme','--restore',good,'--plan');assert.notEqual(r.code,4,`${good}: ${r.err}`);
}
});
});
test('T44 row 180 item 2: --plan lists tester-port-invalid and tester-port-target with its other refusals',async(t)=>{
const usageText=async(e)=>(await run(e,'--help')).err;
await t.test('a bad tester port',async(t)=>{
const e=await mk(t);await seed(e,{pm:true});biz(e,HARNESS);
const before=fp(e),n=calls(e).length,logs=logLines(e).length,p0=readFileSync(e.pointer,'utf8');
const rule=(port,main)=>`--tester-port ${port} is not a whole port from 1024 to 65535 other than 7332${main?` and the Console's --port ${main}`:''}`;
// The fixture's Console has --port e.port; a pointer without --port has the default 7332, which
// the reason names once. --plan reads the pointer's flags and not the units, so only it changes.
const noPort=()=>{const q=JSON.parse(p0);q.console=q.console.filter((f,i,a)=>f!=='--port'&&a[i-1]!=='--port');writeFileSync(e.pointer,JSON.stringify(q)+'\n')};
for(const [port,reason,edit] of [['80',rule('80',e.port)],[e.port,rule(e.port,e.port)],['80',rule('80'),noPort],['7332',rule('7332'),noPort]]){
if(edit)edit();
const r=await run(e,'activate',C1,'acme','--pm-after-harness','--plan','--tester-port',port);
assert.equal(r.code,3,r.out+r.err);assert.equal(r.err,'');
assert.deepEqual(r.out.split('\n').filter((l)=>l.startsWith('refuses ')),[`refuses tester-port-invalid: ${reason}`,'refuses pm-after-harness-set: the business file already sets harness ["claude-code"]; use --pm']);
const u=await run(e,'activate',C1,'acme','--pm-after-harness','--tester-port',port);
assert.equal(u.code,4,u.out+u.err);assert.equal(u.out,'');assert.equal(u.err,await usageText(e));
writeFileSync(e.pointer,p0);
}
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
});
await t.test('without --plan a bad port is usage before any pointer read',async(t)=>{
// parse_opts checks it, so a host with no pointer gets usage and not no-pointer (exit 3, a log line).
const e=await mk(t);await seed(e,{pointer:'none'});
const n=calls(e).length,u=await run(e,'activate',C1,'acme','--tester-port','80');
assert.equal(u.code,4,u.out+u.err);assert.equal(u.out,'');assert.equal(u.err,await usageText(e));
assert.deepEqual(logLines(e),[]);assert.deepEqual(startsStops(e,n),[]);
});
await t.test('a target Console without --tester-port',async(t)=>{
const e=await mk(t);await seed(e);biz(e,HARNESS);
const before=fp(e),n=calls(e).length,logs=logLines(e).length;
const r=await run(e,'activate',A,'acme','--tester-port','7333','--plan');
assert.equal(r.code,3,r.out+r.err);assert.equal(r.err,'');
assert.deepEqual(r.out.split('\n').filter((l)=>l.startsWith('refuses ')),[`refuses tester-port-target: ${A}'s Console does not support --tester-port`,`refuses unknown-kind-for-target: the business file names plug-in kind(s) harness, which ${A} does not know`]);
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
await refused(e,['activate',A,'acme','--tester-port','7333'],'tester-port-target',`${A}'s Console does not support --tester-port`);
});
});
+13
View File
@@ -170,6 +170,19 @@ test('tester service invalid ports refuse render and install with exit 4 and lea
assert.deepEqual(readdirSync(unitDir), ['mosaic-webui.service']);
});
// Row 180 (Dewey's row 176 N2): the length counts as written, before the zeros go, as serve
// counts it. 007333 is six digits and refuses; 07333 is five and renders.
test('tester service counts the digits as written before it strips leading zeros', async (t) => {
const dir = tmp(t);
const six = await run(dir, ['render', '--tester-port', '007333']);
assert.equal(six.status, 4, six.stderr);
assert.equal(six.stdout, '');
assert.equal(six.stderr, 'webui-service: --tester-port must be 1024..65535, different from --port and never 7332: 007333\n');
const five = await run(dir, ['render', '--tester-port', '07333']);
assert.equal(five.status, 0, five.stderr);
assert.ok(execStart(five.stdout).endsWith(' --tester-port 07333'), execStart(five.stdout));
});
test('tester service install and status show the tester port without changing send reporting', async (t) => {
const dir = tmp(t);
const unitDir = join(dir, 'units');
+30 -7
View File
@@ -73,14 +73,16 @@ parse_opts() {
--plan) [ "$OP" = activate ] || [ "$OP" = rollback ] || usage; PLAN=1; shift ;;
--pm) [ "$OP" = activate ] || usage; WANT_PM=1; shift ;;
--pm-after-harness) [ "$OP" = activate ] || usage; AFTER_HARNESS=1; shift ;;
--tester-port) [ "$OP" = activate ] && [ $# -ge 2 ] || usage; TESTER_PORT="$2"; valid_tester_port "$TESTER_PORT" 7332 || usage; shift 2 ;;
--tester-port) [ "$OP" = activate ] && [ $# -ge 2 ] && [ -n "$2" ] || usage; TESTER_PORT="$2"; shift 2 ;;
--migrate) [ "$OP" = activate ] || usage; MIGRATE=1; shift ;;
--restore) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}$ ]] || usage; RESTORE="$2"; shift 2 ;;
--restore) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || usage; RESTORE="$2"; shift 2 ;;
--events) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^(0|[1-9][0-9]*)$ ]] || usage; EVENTS="$2"; shift 2 ;;
*) usage ;;
esac
done
[ -z "$EVENTS" ] || [ -n "$RESTORE" ] || usage
# A bad tester port is a usage error; --plan lists it as tester-port-invalid instead (row 180).
[ -z "$TESTER_PORT" ] || [ "$PLAN" = 1 ] || valid_tester_port "$TESTER_PORT" 7332 || usage
# --pm-after-harness replaces --pm (row 169 item 1), so the two never go together.
[ "$WANT_PM" = 0 ] || [ "$AFTER_HARNESS" = 0 ] || usage
local d
@@ -633,8 +635,14 @@ console_for_target() {
RUN_CONSOLE=("${P_CONSOLE[@]}")
DROP_TESTER=""
if [ -n "$TESTER_PORT" ]; then
valid_tester_port "$TESTER_PORT" "$main" || usage
[ "$supported" = 1 ] || refuse tester-port-target "$TO's Console does not support --tester-port"
# --plan lists both with its other refusals (row 180); outside it they end the run.
if ! valid_tester_port "$TESTER_PORT" "$main"; then
[ "$PLAN" = 1 ] || usage
local other=""
[ "$main" = 7332 ] || other=" and the Console's --port $main"
check_refuse tester-port-invalid "--tester-port $TESTER_PORT is not a whole port from 1024 to 65535 other than 7332$other"
fi
[ "$supported" = 1 ] || check_refuse tester-port-target "$TO's Console does not support --tester-port"
RUN_CONSOLE=("${without_tester[@]}" --tester-port "$TESTER_PORT")
elif [ -n "$inherited" ] && [ "$supported" = 0 ]; then
RUN_CONSOLE=("${without_tester[@]}")
@@ -659,10 +667,25 @@ pointer_for_business() {
[ "$P_BUSINESS" = "$BUSINESS" ] || refuse business-mismatch "the pointer is for $P_BUSINESS"
}
# The activation id is the second and the target's 12-character prefix. A second run in the same
# second adds -1 to -9, so ids still sort by time and an id without one is still whole (row 180).
# Callers hold the lock. Sets ACT and POST from one free suffix: no rollback set, backup or
# post-attempt store with that name.
next_activation() {
local second s
second="$(date -u +%Y%m%dT%H%M%SZ)"
for s in "" -1 -2 -3 -4 -5 -6 -7 -8 -9; do
ACT="$second-${TO:0:12}$s" POST="$DATA/bus.post-attempt-$second$s"
[ -e "$STATE/stack-rollback/$ACT" ] || [ -e "$DATA/backups/stack/$ACT" ] || [ -e "$POST" ] || return 0
done
# Every name is another run's, so the refused line names none and closes no started run.
ACT="" POST=""
refuse capture-failed "every activation id for $second-${TO:0:12} is taken; nothing was stopped"
}
new_activation() {
ACT="$(date -u +%Y%m%dT%H%M%SZ)-${TO:0:12}"
next_activation
RS="$STATE/stack-rollback/$ACT"
POST="$DATA/bus.post-attempt-$(date -u +%Y%m%dT%H%M%SZ)"
}
# --- Verbs ---
@@ -677,7 +700,7 @@ do_adopt() {
read_units || refuse check-failed "cannot read $BUS_UNIT or $CONSOLE_UNIT"
[ -n "$U_CLAUDE" ] || refuse no-claude-pin "$BUS_UNIT has no MOSAIC_CLAUDE_BIN"
target_schema "$TO" || refuse copy-schema-unknown "cannot read SCHEMA_VERSION from $COPIES/$TO/packages/bus/src/store.mjs"
ACT="$(date -u +%Y%m%dT%H%M%SZ)-${TO:0:12}"
next_activation
write_pointer "$TO" "$U_PM" 0 "$U_CLAUDE" "$T_SCHEMA" "${U_CONSOLE[@]}"
log_line result ok
echo "adopted $TO for $BUSINESS (pm $U_PM, schema $T_SCHEMA)"
+2
View File
@@ -122,6 +122,8 @@ tester_port_usage() {
tester_port_ok() {
case "$TESTER_PORT" in ''|*[!0-9]*) tester_port_usage ;; esac
# At most five digits as written, before the zeros go, as serve and stack-release.sh count them.
[ "${#TESTER_PORT}" -le 5 ] || tester_port_usage
local tester_port="$TESTER_PORT"
# Compare decimal values, including values written with leading zeros.
while [ "${tester_port#0}" != "$tester_port" ]; do tester_port="${tester_port#0}"; done