Merge the Mac-run fix of #807: two workers in one folder no longer share a sendmark answer file

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-12 02:20:16 +01:00
commit 7db66075a8
7 files changed
+67 -10

No files matched your search

+7 -2
View File
@@ -643,7 +643,7 @@ sendwait() {
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# every call and every worker (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
# it did it right, not even that the turn is over: a reasoning model can restate the
@@ -667,7 +667,12 @@ sendmark() {
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
esac
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok"
# The token carries six characters of the WORKER's id on top of the second and $RANDOM.
# Those two repeat: in zsh (the shell Claude Code runs on a Mac) every subshell draws the
# SAME $RANDOM until the parent shell reads it, and this line is a subshell. Two sendmark
# calls started in one second for two workers in ONE folder shared a token, and one
# worker's answer file replaced the other's (seen on a Mac, 2026-11-12).
tok=$(printf 'm%x%04x%s' "$(date +%s)" "$RANDOM" "$(printf '%s' "$sid" | tr -cd 'a-f0-9' | cut -c1-6)"); mark="DONE_$tok"
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
+7 -2
View File
@@ -505,7 +505,7 @@ sendwait() {
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# every call and every worker (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
# it did it right, not even that the turn is over: a reasoning model can restate the
@@ -529,7 +529,12 @@ sendmark() {
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
esac
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok"
# The token carries six characters of the WORKER's id on top of the second and $RANDOM.
# Those two repeat: in zsh (the shell Claude Code runs on a Mac) every subshell draws the
# SAME $RANDOM until the parent shell reads it, and this line is a subshell. Two sendmark
# calls started in one second for two workers in ONE folder shared a token, and one
# worker's answer file replaced the other's (seen on a Mac, 2026-11-12).
tok=$(printf 'm%x%04x%s' "$(date +%s)" "$RANDOM" "$(printf '%s' "$sid" | tr -cd 'a-f0-9' | cut -c1-6)"); mark="DONE_$tok"
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
@@ -216,7 +216,10 @@ you typed matches the moment it is typed.
generic marker (`BUILD OK`) matched against stale text, either from `from=buffer`
scanning an earlier run or from tmux replaying old screen content as fresh output on an
attach/resize/redraw. A unique-per-call token (`DONE_$RANDOM`) makes both `from` modes
safe.
safe. ⚠️ In a fan-out put something of the WORKER into it too (its id, the loop index): zsh,
the shell Claude Code runs on a Mac, hands every subshell the same `$RANDOM` until the
parent shell reads it, so `( … $RANDOM … ) &` twice draws one number. `sendmark` adds six
characters of the worker's id for that reason.
### 6. `jq` parse error instead of an `errorCode`
+7 -2
View File
@@ -643,7 +643,7 @@ sendwait() {
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# every call and every worker (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
# it did it right, not even that the turn is over: a reasoning model can restate the
@@ -667,7 +667,12 @@ sendmark() {
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
esac
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok"
# The token carries six characters of the WORKER's id on top of the second and $RANDOM.
# Those two repeat: in zsh (the shell Claude Code runs on a Mac) every subshell draws the
# SAME $RANDOM until the parent shell reads it, and this line is a subshell. Two sendmark
# calls started in one second for two workers in ONE folder shared a token, and one
# worker's answer file replaced the other's (seen on a Mac, 2026-11-12).
tok=$(printf 'm%x%04x%s' "$(date +%s)" "$RANDOM" "$(printf '%s' "$sid" | tr -cd 'a-f0-9' | cut -c1-6)"); mark="DONE_$tok"
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
+7 -2
View File
@@ -505,7 +505,7 @@ sendwait() {
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# every call and every worker (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
# it did it right, not even that the turn is over: a reasoning model can restate the
@@ -529,7 +529,12 @@ sendmark() {
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
esac
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok"
# The token carries six characters of the WORKER's id on top of the second and $RANDOM.
# Those two repeat: in zsh (the shell Claude Code runs on a Mac) every subshell draws the
# SAME $RANDOM until the parent shell reads it, and this line is a subshell. Two sendmark
# calls started in one second for two workers in ONE folder shared a token, and one
# worker's answer file replaced the other's (seen on a Mac, 2026-11-12).
tok=$(printf 'm%x%04x%s' "$(date +%s)" "$RANDOM" "$(printf '%s' "$sid" | tr -cd 'a-f0-9' | cut -c1-6)"); mark="DONE_$tok"
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
+4 -1
View File
@@ -216,7 +216,10 @@ you typed matches the moment it is typed.
generic marker (`BUILD OK`) matched against stale text, either from `from=buffer`
scanning an earlier run or from tmux replaying old screen content as fresh output on an
attach/resize/redraw. A unique-per-call token (`DONE_$RANDOM`) makes both `from` modes
safe.
safe. ⚠️ In a fan-out put something of the WORKER into it too (its id, the loop index): zsh,
the shell Claude Code runs on a Mac, hands every subshell the same `$RANDOM` until the
parent shell reads it, so `( … $RANDOM … ) &` twice draws one number. `sendmark` adds six
characters of the worker's id for that reason.
### 6. `jq` parse error instead of an `errorCode`
+31
View File
@@ -773,6 +773,37 @@ describe.skipIf(!hasJq)('preamble sendmark', () => {
expect(run.calls.some((call) => call.key === 'delete')).toBe(false);
});
// Seen on a Mac (2026-11-12): Claude Code runs zsh there, and zsh hands every subshell the same
// $RANDOM. A lead sent two workers in ONE folder their tasks in the same second, both prompts
// named the same answer file, and one worker's summary replaced the other's.
it('gives each worker its own token and answer file, in the same second and with the same $RANDOM', () => {
const other = 'b7e1c2d3-0000-4000-8000-00000000beef';
const run = runSkill(
withSession(
// Two calls at once that draw the same number: what zsh does by itself. bash reseeds its
// generator in every subshell, so here the variable is made a plain one that always reads 7.
`unset RANDOM; RANDOM=7
( sendmark ${SID} 'count to three' 1 >/dev/null ) &
( sendmark ${other} 'count to three' 1 >/dev/null ) &
wait; true`
),
{ input: delivered, 'wait-output': NOT_MATCHED, 'wait-output.delay': '1' }
);
expect(run.stderr).toBe('');
const tokens = run.calls
.filter((call) => call.key === 'input')
.map((call) => /codeman-answer-(m[0-9a-f]+)\.md/.exec((JSON.parse(call.d) as { input: string }).input)?.[1] ?? '')
.sort();
expect(tokens).toHaveLength(2);
// m, eight digits of the second, four of $RANDOM, six of the worker's id.
for (const token of tokens) expect(token).toMatch(/^m[0-9a-f]{18}$/);
// The same draw in both calls...
expect(tokens.map((token) => token.slice(9, 13))).toEqual(['0007', '0007']);
// ...and still two files, told apart by the workers' own ids.
expect(tokens[0]).not.toBe(tokens[1]);
expect(tokens.map((token) => token.slice(13)).sort()).toEqual(['b7e1c2', 'eca000']);
});
it('reads the answer file without a marker once it held still for a round (a full-screen pane draws the marker in pieces)', () => {
// Measured on opencode 1.3.0: a reply streamed a character or two at a time reaches the
// stripped stream as D, spinner, O, spinner, N, ... and never matches, while the screen