Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d27700026 | ||
|
|
d6302f8e6f |
@@ -46,7 +46,7 @@ whitelisted — see the tool header.
|
|||||||
| tmux | `tools/tmux/agent-send.sh` | inter-agent messaging (see "Most-used" above) |
|
| tmux | `tools/tmux/agent-send.sh` | inter-agent messaging (see "Most-used" above) |
|
||||||
| git | `tools/git/*.sh` | issues, PRs, milestones, CI queue guard (platform-auto-detected) |
|
| git | `tools/git/*.sh` | issues, PRs, milestones, CI queue guard (platform-auto-detected) |
|
||||||
| woodpecker | `tools/woodpecker/*.sh` | CI pipelines (`-a mosaic`\|`usc`; match git remote host) |
|
| woodpecker | `tools/woodpecker/*.sh` | CI pipelines (`-a mosaic`\|`usc`; match git remote host) |
|
||||||
| portainer | `tools/portainer/*.sh` | Docker Swarm stacks (status/redeploy/list) |
|
| portainer | `tools/portainer/*.sh` | Optional Docker Swarm tools when a Portainer credential is available |
|
||||||
| coolify | `tools/coolify/*.sh` | **DEPRECATED** — superseded by Portainer; do not use for new deployments |
|
| coolify | `tools/coolify/*.sh` | **DEPRECATED** — superseded by Portainer; do not use for new deployments |
|
||||||
| authentik | `tools/authentik/*.sh` | identity (users/groups/apps/flows) |
|
| authentik | `tools/authentik/*.sh` | identity (users/groups/apps/flows) |
|
||||||
| cloudflare | `tools/cloudflare/*.sh` | DNS (zones/records; `-a` instance) |
|
| cloudflare | `tools/cloudflare/*.sh` | DNS (zones/records; `-a` instance) |
|
||||||
|
|||||||
@@ -53,21 +53,23 @@ sends, it does not auto-reply.
|
|||||||
|
|
||||||
### Exit codes
|
### Exit codes
|
||||||
|
|
||||||
| rc | Meaning |
|
| rc | Meaning |
|
||||||
| --- | ---------------------------------------------- |
|
| --- | -------------------------------------------------------------------------------------------- |
|
||||||
| 0 | delivered or queued |
|
| 0 | delivered or queued |
|
||||||
| 1 | target session not found |
|
| 1 | target session not found |
|
||||||
| 2 | text reached the pane but is **still a draft** |
|
| 2 | submission unconfirmed: draft still on the input line, or no positive evidence of submission |
|
||||||
| 3 | usage error (bad class, missing `-s`) |
|
| 3 | usage error (bad class, missing `-s`) |
|
||||||
|
|
||||||
**Never retry on rc=2.** The message is in the target pane; retrying double-sends it. Confirm
|
**Never retry on rc=2.** The message may be in the target pane, and a retry can double-send it.
|
||||||
instead:
|
Confirm instead:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tmux capture-pane -p -t <session>:0.0 | tail -20
|
tmux capture-pane -p -t <session>:0.0 | tail -20
|
||||||
```
|
```
|
||||||
|
|
||||||
rc=2 is the normal result when the target is an idle pi seat.
|
rc=0 is the normal result for both idle and busy pi seats (submission confirmed by draft
|
||||||
|
transition, not by prompt glyph). rc=2 on a healthy seat is exceptional — treat it as a real
|
||||||
|
report and investigate the pane.
|
||||||
|
|
||||||
## Durable comms
|
## Durable comms
|
||||||
|
|
||||||
|
|||||||
@@ -136,7 +136,8 @@ The human is escalation-only for missing access, hard policy conflicts, or irrev
|
|||||||
|
|
||||||
### Supported Targets
|
### Supported Targets
|
||||||
|
|
||||||
- **Portainer**: Deploy via `~/.config/mosaic/tools/portainer/stack-redeploy.sh`, then verify with `stack-status.sh`.
|
- **Docker Swarm**: If a stack README documents `docker stack deploy` on the manager, use that deploy path and its stated verification procedure.
|
||||||
|
- **Portainer (optional)**: Use only when the estate holds a Portainer credential. Do not propose Portainer otherwise. Deploy via `~/.config/mosaic/tools/portainer/stack-redeploy.sh`, then verify with `stack-status.sh`.
|
||||||
- **Coolify**: Deploy via `~/.config/mosaic/tools/coolify/deploy.sh -u <uuid>`, then verify with `service-status.sh`.
|
- **Coolify**: Deploy via `~/.config/mosaic/tools/coolify/deploy.sh -u <uuid>`, then verify with `service-status.sh`.
|
||||||
- **Vercel**: Deploy via `vercel` CLI or connected Git integration, then verify preview/production URL health.
|
- **Vercel**: Deploy via `vercel` CLI or connected Git integration, then verify preview/production URL health.
|
||||||
- **Other SaaS providers**: Use provider CLI/API/runbook with the same validation and rollback gates.
|
- **Other SaaS providers**: Use provider CLI/API/runbook with the same validation and rollback gates.
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
---
|
---
|
||||||
name: mosaic-deploy
|
name: mosaic-deploy
|
||||||
description: 'Full end-to-end deploy flow for Mosaic Stack projects: push branch → open PR → wait for CI → merge → redeploy Portainer stack. Use when deploying a feature branch to production or staging, or when asked to ship a completed feature. Orchestrates mosaic-gitea, mosaic-woodpecker, and mosaic-portainer skills.'
|
description: 'Full end-to-end deployment flow: push branch → open PR → wait for CI → merge → deploy using the path documented by the stack. Use when deploying a feature branch to production or staging, or when asked to ship a completed feature.'
|
||||||
---
|
---
|
||||||
|
|
||||||
# mosaic-deploy
|
# mosaic-deploy
|
||||||
|
|
||||||
End-to-end deployment flow for Mosaic Stack projects.
|
End-to-end deployment flow.
|
||||||
|
|
||||||
## Full Deploy Sequence
|
## Full Deploy Sequence
|
||||||
|
|
||||||
```
|
```
|
||||||
push branch → open PR → CI passes → merge → portainer redeploy
|
push branch → open PR → CI passes → merge → documented deploy path
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 1: Push branch and open PR
|
### Step 1: Push branch and open PR
|
||||||
@@ -49,25 +49,32 @@ review. Fix the cause; never route around it with a raw API call, a shared
|
|||||||
credential, or `force_merge`. Exceptional cases go to the operator or the
|
credential, or `force_merge`. Exceptional cases go to the operator or the
|
||||||
coordinating seat, still merged through the wrapper.
|
coordinating seat, still merged through the wrapper.
|
||||||
|
|
||||||
### Step 4: Redeploy Portainer stack
|
### Step 4: Deploy Through the Documented Path
|
||||||
|
|
||||||
|
Read the stack README before deploying:
|
||||||
|
|
||||||
|
- If it documents `docker stack deploy` on the manager, use that deploy path and its verification procedure.
|
||||||
|
- Use Portainer only when the estate holds a Portainer credential. Do not propose Portainer otherwise.
|
||||||
|
|
||||||
|
For an authorized Portainer deployment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials portainer
|
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials portainer
|
||||||
~/.config/mosaic/tools/portainer/stack-redeploy.sh -n <stack-name> -p
|
~/.config/mosaic/tools/portainer/stack-redeploy.sh -n <stack-name> -p
|
||||||
```
|
```
|
||||||
|
|
||||||
Check deployment:
|
Check a Portainer deployment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
~/.config/mosaic/tools/portainer/stack-status.sh -n <stack-name>
|
~/.config/mosaic/tools/portainer/stack-status.sh -n <stack-name>
|
||||||
~/.config/mosaic/tools/portainer/stack-logs.sh -n <stack-name> -l 50
|
~/.config/mosaic/tools/portainer/stack-logs.sh -n <stack-name> -l 50
|
||||||
```
|
```
|
||||||
|
|
||||||
## Stack Name Map
|
## Optional Portainer Stack Map
|
||||||
|
|
||||||
Maintain your estate's project → stack-name mapping in a skills-local override of
|
For deployments that use Portainer, maintain a project → stack-name mapping in a
|
||||||
this skill (local copies take precedence over the shipped canonical one). Example
|
skills-local override of this skill (local copies take precedence over the shipped
|
||||||
shape:
|
canonical one). Example shape:
|
||||||
|
|
||||||
| Project | Stack Name |
|
| Project | Stack Name |
|
||||||
| ------------ | ----------------- |
|
| ------------ | ----------------- |
|
||||||
@@ -77,6 +84,6 @@ shape:
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Workers open PRs but **never merge** — orchestrator or Merge Guard handles step 3+
|
- Workers open PRs but **never merge** — orchestrator or Merge Guard handles step 3+
|
||||||
- Docker Swarm image pinning: if `-p` doesn't pull a new image, SSH to the Docker node (e.g. `node-01`) and run `docker pull <image>` manually, then redeploy
|
- Docker Swarm image pinning: `-p` does not change a digest-pinned image. Follow the stack README's documented deployment procedure.
|
||||||
- Worktrees: all coding work in `~/src/<repo>-worktrees/<task-slug>`, never in main checkout
|
- Worktrees: all coding work in `~/src/<repo>-worktrees/<task-slug>`, never in main checkout
|
||||||
- Always clean up worktree after push: `git worktree remove ~/src/<repo>-worktrees/<task-slug>`
|
- Always clean up worktree after push: `git worktree remove ~/src/<repo>-worktrees/<task-slug>`
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
---
|
---
|
||||||
name: mosaic-portainer
|
name: mosaic-portainer
|
||||||
description: Manage Portainer stacks on the Mosaic infrastructure. Use when asked to list, start, stop, redeploy, or check logs of Docker Swarm stacks via Portainer. Wraps scripts in ~/.config/mosaic/tools/portainer/. Requires load_credentials portainer first.
|
description: Manage Docker Swarm stacks through Portainer when a Portainer credential is available. Use when asked to list, start, stop, redeploy, or check logs through Portainer.
|
||||||
---
|
---
|
||||||
|
|
||||||
# mosaic-portainer
|
# mosaic-portainer
|
||||||
|
|
||||||
Manage Portainer stacks via pre-built Mosaic scripts.
|
Manage Portainer stacks through supplied scripts.
|
||||||
|
|
||||||
|
## Decision Gate
|
||||||
|
|
||||||
|
Portainer is optional. Use this skill only when the estate holds a Portainer credential. If a stack README documents `docker stack deploy` on the manager, that is the deploy path. Do not propose Portainer otherwise.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
Always load credentials before running scripts:
|
After confirming a Portainer credential is available, load it before running scripts:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
source ~/.config/mosaic/tools/_lib/credentials.sh
|
source ~/.config/mosaic/tools/_lib/credentials.sh
|
||||||
@@ -33,11 +37,11 @@ All scripts live in `~/.config/mosaic/tools/portainer/`.
|
|||||||
|
|
||||||
## Common Workflows
|
## Common Workflows
|
||||||
|
|
||||||
**Redeploy a stack with fresh images:**
|
**Redeploy a stack through Portainer:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials portainer
|
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials portainer
|
||||||
~/.config/mosaic/tools/portainer/stack-redeploy.sh -n mosaic-stack -p
|
~/.config/mosaic/tools/portainer/stack-redeploy.sh -n <stack-name> -p
|
||||||
```
|
```
|
||||||
|
|
||||||
**Check all stack statuses:**
|
**Check all stack statuses:**
|
||||||
@@ -51,12 +55,10 @@ source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials portainer
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials portainer
|
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials portainer
|
||||||
~/.config/mosaic/tools/portainer/stack-logs.sh -n mosaic-stack -l 100
|
~/.config/mosaic/tools/portainer/stack-logs.sh -n <stack-name> -l 100
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Portainer URL: `https://portainer.example.internal:9443`
|
- `stack-redeploy.sh -p` does not override a digest-pinned image. Follow the stack README's documented deployment procedure for pinned images.
|
||||||
- Primary Docker host: `node-01`, managed via Portainer agent
|
- Credentials are loaded through `load_credentials portainer`.
|
||||||
- Docker Swarm image updates: `stack-redeploy.sh -p` does NOT guarantee new image pull if digest is pinned; SSH to node and `docker pull` first if needed
|
|
||||||
- Credentials: `load_credentials portainer` (framework credentials store)
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh | unmeasured i
|
|||||||
# --- tools/tmux: require a live tmux server ---
|
# --- tools/tmux: require a live tmux server ---
|
||||||
packages/mosaic/framework/tools/tmux/test-send-message-socket.sh | requires a real tmux server on a throwaway socket; CI image ships no tmux; #1017 burndown (needs tmux in image or a signed permanent exclusion)
|
packages/mosaic/framework/tools/tmux/test-send-message-socket.sh | requires a real tmux server on a throwaway socket; CI image ships no tmux; #1017 burndown (needs tmux in image or a signed permanent exclusion)
|
||||||
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
|
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
|
||||||
|
packages/mosaic/framework/tools/tmux/test-send-message-glyph-agnostic.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its siblings) — signed at adoption of #1262 (rev-code-02 F5), red-first verified on sb-it-1-dt
|
||||||
|
|
||||||
# --- single-suite directories: unmeasured in CI ---
|
# --- single-suite directories: unmeasured in CI ---
|
||||||
|
|
||||||
|
|||||||
@@ -33,8 +33,10 @@
|
|||||||
# 1 tmux target not found
|
# 1 tmux target not found
|
||||||
# 2 submission NOT confirmed — either still an unsubmitted draft, or the REPL
|
# 2 submission NOT confirmed — either still an unsubmitted draft, or the REPL
|
||||||
# input box could not be located to confirm the message actually landed.
|
# input box could not be located to confirm the message actually landed.
|
||||||
# Locating the box is runtime-specific; see locate_input_box() below, and
|
# Delivered verdicts are runtime-agnostic (cursor-row draft transition, or
|
||||||
# add a shape there before pointing this tool at a new runtime.
|
# the queued banner); locate_input_box() below adds positive DRAFT evidence
|
||||||
|
# for panes that render a recognizable box, and never gates delivery on a
|
||||||
|
# runtime's rendering shape.
|
||||||
# Delivery is NEVER inferred from absence of evidence: if we cannot positively
|
# Delivery is NEVER inferred from absence of evidence: if we cannot positively
|
||||||
# see the input box clear of the message (or the queued banner), we fail loud
|
# see the input box clear of the message (or the queued banner), we fail loud
|
||||||
# so the sender learns immediately instead of a silent worker->lead stall.
|
# so the sender learns immediately instead of a silent worker->lead stall.
|
||||||
@@ -99,11 +101,33 @@ printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -
|
|||||||
# would otherwise accumulate forever.
|
# would otherwise accumulate forever.
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
|
|
||||||
|
# 2) Submit, then POSITIVELY confirm submission by DRAFT TRANSITION, not by prompt
|
||||||
|
# glyph. The historical bug was treating ABSENCE of a draft as delivery; the
|
||||||
|
# 2026-08 fix over-corrected to glyph inference (grep '❯|^>|│ >'), which locates
|
||||||
|
# only Claude Code's box and false-NEGATIVES every glyphless REPL (pi renders a
|
||||||
|
# U+2500 rule, no glyph) — a delivered message reported "UNDELIVERED", driving a
|
||||||
|
# retry that duplicates it. Runtime-agnostic evidence: our message tail sits on
|
||||||
|
# the INPUT line (located by the cursor row, not a glyph) BEFORE Enter, and has
|
||||||
|
# LEFT it AFTER — that transition is positive proof of submission and needs no
|
||||||
|
# glyph. Absence alone still never means delivered: if we never saw our draft on
|
||||||
|
# the input line we stay UNCONFIRMED (wrong/dead pane), and a draft that never
|
||||||
|
# leaves the input line stays a DRAFT (exit 2), preserving both historical guards.
|
||||||
|
_cursor_line() { # echo the pane's current input (cursor) line, glyph-free
|
||||||
|
local cy line
|
||||||
|
cy=$("${tmux_cmd[@]}" display-message -p -t "$EFFECTIVE_TARGET" -F '#{cursor_y}' 2>/dev/null) || return 1
|
||||||
|
[ -n "$cy" ] || return 1
|
||||||
|
"${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null | sed -n "$((cy + 1))p"
|
||||||
|
}
|
||||||
|
_draft_on_input() { # true iff our message tail is sitting on the input line now
|
||||||
|
[ -n "$snippet" ] || return 1
|
||||||
|
grep -qF "$snippet" <<<"$(_cursor_line)"
|
||||||
|
}
|
||||||
|
|
||||||
# Locate the REPL input box in a captured pane. Prints the box's contents on
|
# Locate the REPL input box in a captured pane. Prints the box's contents on
|
||||||
# stdout and returns 0 when the box was FOUND; returns 1 when it could not be
|
# stdout and returns 0 when the box was FOUND; returns 1 when it could not
|
||||||
# located at all. Found-but-empty is a real, distinct answer (an empty input box
|
# be located at all. Found-but-empty is a real, distinct answer (an empty input
|
||||||
# is what a submitted message leaves behind), so the caller must branch on the
|
# box is what a submitted message leaves behind), so the caller must branch on
|
||||||
# return code, never on whether the output is empty.
|
# the return code, never on whether the output is empty.
|
||||||
#
|
#
|
||||||
# Two REPL shapes are recognised:
|
# Two REPL shapes are recognised:
|
||||||
# * a prompt-glyph line — `❯`, a leading `>`, or `│ >`. Claude Code and most
|
# * a prompt-glyph line — `❯`, a leading `>`, or `│ >`. Claude Code and most
|
||||||
@@ -113,10 +137,15 @@ sleep 0.5
|
|||||||
# what makes it safe: agent output can contain its own rules, but nothing is
|
# what makes it safe: agent output can contain its own rules, but nothing is
|
||||||
# drawn below the input box except the status line.
|
# drawn below the input box except the status line.
|
||||||
#
|
#
|
||||||
# Adding a runtime means adding its shape HERE. A shape that is missing does not
|
# Compose authority rule (#1332 O1): this function is POSITIVE DRAFT EVIDENCE
|
||||||
# degrade gracefully: it turns every send to that runtime into a false
|
# ONLY. A located box still carrying our tail is affirmative proof the message
|
||||||
# "may be UNDELIVERED", which is what #1362 measured on pi and #1257 on another
|
# was not consumed (the cursor-row check's blind spot: a redrawn TUI can park
|
||||||
# arm of the same probe.
|
# the cursor off the input line, which the draft-transition anchor cannot see).
|
||||||
|
# Its failure to find a box proves NOTHING and must never produce an
|
||||||
|
# UNDELIVERED verdict: a shapeless-but-submitting pane delivers via the
|
||||||
|
# cursor-row transition regardless (measured, scratch probe 2026-09-04;
|
||||||
|
# shapeless REPL consumed the message while shape probing alone reported
|
||||||
|
# "may be UNDELIVERED" — the exact #1257 regression this split prevents).
|
||||||
locate_input_box() {
|
locate_input_box() {
|
||||||
local pane=$1 glyph_line rule_lines top bottom
|
local pane=$1 glyph_line rule_lines top bottom
|
||||||
glyph_line=$(printf '%s\n' "$pane" | grep -E '❯|^>|│ >' | tail -1)
|
glyph_line=$(printf '%s\n' "$pane" | grep -E '❯|^>|│ >' | tail -1)
|
||||||
@@ -139,13 +168,12 @@ locate_input_box() {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# 2) Submit, then POSITIVELY confirm submission; flush with another Enter if it is
|
# Baseline: after the paste, our draft must be on the input line. This is positive
|
||||||
# still a draft. Success requires positive evidence — the queued banner, OR the
|
# proof we are on the right pane and the paste landed — the anchor the transition
|
||||||
# REPL input box located AND clear of our message tail. The historical bug was
|
# check measures against.
|
||||||
# treating ABSENCE of a draft as delivery: if the input box was never located
|
saw_draft=0
|
||||||
# (wrong pane / prompt-glyph drift), an unsubmitted message read as "delivered"
|
_draft_on_input && saw_draft=1
|
||||||
# and worker->lead relays stalled silently. We now default to UNCONFIRMED and only
|
|
||||||
# upgrade to delivered on positive evidence; anything we cannot confirm fails loud.
|
|
||||||
status="unconfirmed"
|
status="unconfirmed"
|
||||||
for attempt in $(seq 1 $((RETRIES + 1))); do
|
for attempt in $(seq 1 $((RETRIES + 1))); do
|
||||||
"${tmux_cmd[@]}" send-keys -t "$EFFECTIVE_TARGET" Enter
|
"${tmux_cmd[@]}" send-keys -t "$EFFECTIVE_TARGET" Enter
|
||||||
@@ -155,19 +183,30 @@ for attempt in $(seq 1 $((RETRIES + 1))); do
|
|||||||
if grep -qF "$QUEUED_RE" <<<"$pane"; then
|
if grep -qF "$QUEUED_RE" <<<"$pane"; then
|
||||||
status="queued"; break
|
status="queued"; break
|
||||||
fi
|
fi
|
||||||
# If we cannot see the input box, we have NO evidence of submission state —
|
# POSITIVE draft evidence from a located input box, when one exists. This is
|
||||||
# stay UNCONFIRMED and retry; never infer delivery.
|
# the cursor-row check's blind spot: a redrawn TUI (pi's box) can park the
|
||||||
if ! inputbox=$(locate_input_box "$pane"); then
|
# cursor off the input line, which the draft-transition anchor cannot see,
|
||||||
status="unconfirmed"; continue
|
# while a pane in COOKED mode (a plain shell whose foreground process never
|
||||||
|
# reads stdin) echoes our paste via the kernel line discipline and moves the
|
||||||
|
# cursor off it on Enter, indistinguishable from a real submit by cursor row
|
||||||
|
# alone. If a locatable box still carries our tail, that is affirmative proof
|
||||||
|
# the message was not consumed. Absence of a recognizable shape is never used
|
||||||
|
# for anything — that inference is the original E7 bug, and the delivered
|
||||||
|
# verdict stays with the runtime-agnostic cursor-row transition.
|
||||||
|
if inputbox=$(locate_input_box "$pane"); then
|
||||||
|
if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$inputbox"; then
|
||||||
|
status="draft"; continue
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
# Input box located AND still carrying our tail => unsubmitted draft. Flush + retry.
|
if [ "$saw_draft" = 1 ]; then
|
||||||
# (Submitted messages scroll up into history; a draft stays in the box.)
|
if _draft_on_input; then
|
||||||
if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$inputbox"; then
|
status="draft"; continue # still on the input line => not submitted; flush + retry
|
||||||
status="draft"; continue
|
fi
|
||||||
|
status="delivered"; break # left the input line => positively submitted
|
||||||
fi
|
fi
|
||||||
# Input box located AND clear of our tail => positively submitted. This is the
|
# No confirmed baseline yet: try to (re)acquire it; never infer delivery from absence.
|
||||||
# only path to success besides the queued banner.
|
if _draft_on_input; then saw_draft=1; status="draft"; continue; fi
|
||||||
status="delivered"; break
|
status="unconfirmed"; continue
|
||||||
done
|
done
|
||||||
|
|
||||||
[ "$VERBOSE" = 1 ] && { echo "--- pane tail ($TARGET) ---"; printf '%s\n' "$pane" | tail -4; echo "---"; }
|
[ "$VERBOSE" = 1 ] && { echo "--- pane tail ($TARGET) ---"; printf '%s\n' "$pane" | tail -4; echo "---"; }
|
||||||
@@ -176,6 +215,6 @@ case "$status" in
|
|||||||
delivered) echo "✓ delivered to $TARGET"; exit 0 ;;
|
delivered) echo "✓ delivered to $TARGET"; exit 0 ;;
|
||||||
queued) echo "✓ queued to $TARGET (agent busy — will process when it returns to prompt)"; exit 0 ;;
|
queued) echo "✓ queued to $TARGET (agent busy — will process when it returns to prompt)"; exit 0 ;;
|
||||||
draft) echo "✗ still an unsubmitted draft on $TARGET after $RETRIES flush attempts" >&2; exit 2 ;;
|
draft) echo "✗ still an unsubmitted draft on $TARGET after $RETRIES flush attempts" >&2; exit 2 ;;
|
||||||
unconfirmed) echo "✗ could not confirm submission on $TARGET: REPL input box not locatable after $((RETRIES + 1)) attempts — message may be UNDELIVERED (check target/pane, retry, or escalate)" >&2; exit 2 ;;
|
unconfirmed) echo "✗ could not confirm submission on $TARGET: REPL input prompt not locatable after $((RETRIES + 1)) attempts — message may be UNDELIVERED (check target/pane, retry, or escalate)" >&2; exit 2 ;;
|
||||||
*) echo "✗ could not confirm submission on $TARGET (unexpected state '$status')" >&2; exit 2 ;;
|
*) echo "✗ could not confirm submission on $TARGET (unexpected state '$status')" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Red-first regression test for E7 (#1017 task 2): the confirm-check must bind
|
||||||
|
# "delivered" to WHETHER THE MESSAGE WAS SUBMITTED, not to which runtime's prompt
|
||||||
|
# glyph is present. A pi seat renders a U+2500 rule input box with no ❯/^>/│ >
|
||||||
|
# glyph; send-message.sh:118 locates the box only by glyph, so a genuinely
|
||||||
|
# delivered message on a glyphless REPL falsely reports exit 2 "may be UNDELIVERED",
|
||||||
|
# and the operator's rc=2-driven retry duplicates it.
|
||||||
|
#
|
||||||
|
# Parameterized on $SEND: RED against the shipping blob (B and D fail), GREEN
|
||||||
|
# against a candidate patch. No pi; no fake HOME; hermetic throwaway socket.
|
||||||
|
#
|
||||||
|
# Submission counting is EXACT and terminal-echo-independent: the fixture message
|
||||||
|
# is `echo <tok> >>SINK`; each real submission appends one line. wc -l SINK ==
|
||||||
|
# number of times the REPL actually executed the send. This does not depend on how
|
||||||
|
# many times the marker string is painted on screen.
|
||||||
|
set -u
|
||||||
|
SEND="${SEND:?set SEND=/path/to/send-message.sh}"
|
||||||
|
SOCKET="glyphagnostic-$$"
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
tmux() { command tmux -L "$SOCKET" "$@"; }
|
||||||
|
cleanup() { command tmux -L "$SOCKET" kill-server 2>/dev/null; rm -rf "$TMP"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
pass=0; fail=0
|
||||||
|
ok() { printf 'ok %s\n' "$1"; pass=$((pass+1)); }
|
||||||
|
no() { printf 'FAIL %s -- %s\n' "$1" "$2"; fail=$((fail+1)); }
|
||||||
|
|
||||||
|
mk() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" "PS1='$2' exec bash --noprofile --norc -i"; sleep 0.5; }
|
||||||
|
subs() { [ -f "$1" ] && wc -l <"$1" | tr -d ' ' || echo 0; } # exact submission count
|
||||||
|
|
||||||
|
echo "SEND=$SEND tmux $(command tmux -V | awk '{print $2}')"
|
||||||
|
|
||||||
|
# --- A (control): glyph box (❯) that submits => exit 0, exactly one submission.
|
||||||
|
mk ctl '❯ '
|
||||||
|
SINK="$TMP/sink.ctl"
|
||||||
|
out=$("$SEND" -L "$SOCKET" -t ctl -m "echo x >>'$SINK'" 2>"$TMP/e.ctl"); rc=$?; sleep 0.4
|
||||||
|
if [ "$rc" = 0 ] && [ "$(subs "$SINK")" = 1 ]; then
|
||||||
|
ok "control: ❯-box submits => exit 0, exactly one submission"
|
||||||
|
else no "control: ❯-box submits => exit 0, one submission" "rc=$rc subs=$(subs "$SINK") err=[$(cat "$TMP/e.ctl")]"; fi
|
||||||
|
|
||||||
|
# --- B (THE false-rc regression): glyphless U+2500 box that SUBMITS. Message lands
|
||||||
|
# (subs==1) yet shipping reports exit 2. Must be exit 0.
|
||||||
|
mk sub $'──────── \n'
|
||||||
|
SINK="$TMP/sink.sub"
|
||||||
|
out=$("$SEND" -L "$SOCKET" -t sub -m "echo x >>'$SINK'" 2>"$TMP/e.sub"); rc=$?; sleep 0.4
|
||||||
|
if [ "$rc" = 0 ] && [ "$(subs "$SINK")" = 1 ]; then
|
||||||
|
ok "glyphless: U+2500 box that submits => exit 0 (delivered, not 'UNDELIVERED')"
|
||||||
|
else no "glyphless: U+2500 box that submits => exit 0" \
|
||||||
|
"rc=$rc subs=$(subs "$SINK")(delivered=$([ "$(subs "$SINK")" -ge 1 ] && echo yes||echo no)) err=[$(cat "$TMP/e.sub")]"; fi
|
||||||
|
|
||||||
|
# --- D (duplicate arm): operator follows the rc=2 stderr and retries once. On the
|
||||||
|
# glyphless box, shipping => two submissions (the reported duplicate). The
|
||||||
|
# property: one logical send => exactly one submission. Same fix closes it.
|
||||||
|
mk dup $'──────── \n'
|
||||||
|
SINK="$TMP/sink.dup"
|
||||||
|
tries=0
|
||||||
|
for attempt in 1 2; do
|
||||||
|
tries=$((tries+1))
|
||||||
|
out=$("$SEND" -L "$SOCKET" -t dup -m "echo x >>'$SINK'" 2>/dev/null); rc=$?
|
||||||
|
sleep 0.4
|
||||||
|
[ "$rc" = 0 ] && break # operator stops retrying only when told delivered
|
||||||
|
done
|
||||||
|
if [ "$(subs "$SINK")" = 1 ]; then
|
||||||
|
ok "duplicate: one logical send (rc-driven retry) => exactly one submission (tries=$tries)"
|
||||||
|
else no "duplicate: one logical send => exactly one submission" "submissions=$(subs "$SINK") tries=$tries"; fi
|
||||||
|
|
||||||
|
# --- E (faithful hung managed TUI, NOT a cooked shell): raw/no-echo, paints nothing.
|
||||||
|
# A cooked `sleep infinity` echoes the paste via the kernel line discipline and
|
||||||
|
# false-passes a cursor-row fix that is correct on real seats (measured). So: raw.
|
||||||
|
mk_rawstuck() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" \
|
||||||
|
"bash --noprofile --norc -c 'stty -echo -icanon min 1 time 0 2>/dev/null; exec sleep infinity'"; sleep 0.5; }
|
||||||
|
mk_rawstuck estuck
|
||||||
|
SINK="$TMP/sink.estuck"
|
||||||
|
out=$("$SEND" -L "$SOCKET" -t estuck -r 1 -m "this stuck draft was never submitted" 2>/dev/null); rc=$?
|
||||||
|
sleep 0.3
|
||||||
|
if [ "$rc" != 0 ] && [ "$(subs "$SINK")" = 0 ]; then
|
||||||
|
ok "raw/no-echo stuck TUI (not submitted) => non-zero (no false delivered)"
|
||||||
|
else no "raw stuck TUI must NOT report delivered" "rc=$rc subs=$(subs "$SINK")"; fi
|
||||||
|
|
||||||
|
# --- F (busy/queued branch, your BUSY-not-runtime finding): glyphless pane rendering the
|
||||||
|
# queued banner, never consuming. QUEUED_RE :113 fires before the glyph grep => rc=0.
|
||||||
|
mk_busy() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" \
|
||||||
|
"bash --noprofile --norc -c 'printf \"Press up to edit queued messages\n\"; exec sleep infinity'"; sleep 0.5; }
|
||||||
|
mk_busy ebusy
|
||||||
|
SINK="$TMP/sink.ebusy"
|
||||||
|
out=$("$SEND" -L "$SOCKET" -t ebusy -m "echo x >>'$SINK'" 2>/dev/null); rc=$?; sleep 0.3
|
||||||
|
if [ "$rc" = 0 ]; then
|
||||||
|
ok "busy/queued-banner glyphless => exit 0 (queued is delivery; runtime owns custody)"
|
||||||
|
else no "busy/queued-banner must report delivered" "rc=$rc"; fi
|
||||||
|
|
||||||
|
# --- C (historical-bug guard): unresolvable target. No pane ever carried our draft
|
||||||
|
# => must fail, never infer delivered from absence of a glyph/snippet.
|
||||||
|
if out=$("$SEND" -L "$SOCKET" -t "nonexistent-$$" -m "echo x >>'$TMP/sink.wrong'" 2>/dev/null); then
|
||||||
|
no "wrong-pane: unresolvable target must NOT report success" "expected non-zero, got 0"
|
||||||
|
else ok "wrong-pane: unresolvable target => non-zero (no false delivered)"; fi
|
||||||
|
|
||||||
|
echo "---"; echo "pass=$pass fail=$fail"
|
||||||
|
[ "$fail" = 0 ]
|
||||||
@@ -4,10 +4,13 @@
|
|||||||
#
|
#
|
||||||
# 1. DELIVERED — a REPL that renders a `❯ ` input box and submits on Enter
|
# 1. DELIVERED — a REPL that renders a `❯ ` input box and submits on Enter
|
||||||
# (text scrolls to history, box clears) => exit 0 "✓ delivered".
|
# (text scrolls to history, box clears) => exit 0 "✓ delivered".
|
||||||
# 2. UNCONFIRMED — a pane with NO locatable prompt glyph. This is the exact
|
# 2. DELIVERED — a pane with NO prompt glyph that DOES submit => exit 0. A pi
|
||||||
# historical FALSE POSITIVE: pre-patch it printed "✓ delivered"
|
# seat is this fixture (U+2500 rule, no glyph). Reshaped for
|
||||||
# exit 0; post-patch it MUST fail loud (exit 2, stderr
|
# #1257; see the note at the fixture for why the old exit-2
|
||||||
# "could not confirm submission").
|
# assertion was wrong.
|
||||||
|
# 2b. UNCONFIRMED— a glyphless pane that never submits (raw/no-echo hung TUI)
|
||||||
|
# => must fail loud. This carries the historical
|
||||||
|
# false-positive guard that fixture 2 used to be credited with.
|
||||||
# 3. DRAFT — a `❯ `-prompt pane that never submits (message stays on the
|
# 3. DRAFT — a `❯ `-prompt pane that never submits (message stays on the
|
||||||
# input line) => exit 2, stderr "unsubmitted draft".
|
# input line) => exit 2, stderr "unsubmitted draft".
|
||||||
# 4. DELIVERED — a pane whose input box is two `─` rules with NO prompt glyph
|
# 4. DELIVERED — a pane whose input box is two `─` rules with NO prompt glyph
|
||||||
@@ -17,10 +20,16 @@
|
|||||||
# 5. DRAFT — the same glyphless box, holding our tail across every flush
|
# 5. DRAFT — the same glyphless box, holding our tail across every flush
|
||||||
# (box shape) Enter => exit 2, stderr "unsubmitted draft". Pre-#1362 this
|
# (box shape) Enter => exit 2, stderr "unsubmitted draft". Pre-#1362 this
|
||||||
# also reported unconfirmed, so the true state was invisible.
|
# also reported unconfirmed, so the true state was invisible.
|
||||||
|
# 6. DELIVERED — a SHAPELESS REPL (no glyph, no box) that submits => exit 0.
|
||||||
|
# The 2026-09-04 scratch probe regression: shape probing alone reports "may
|
||||||
|
# be UNDELIVERED" on this pane while the message is consumed; the cursor-row
|
||||||
|
# draft transition is the authoritative runtime-agnostic verdict.
|
||||||
|
# 6b. UNCONFIRMED— a shapeless pane in raw/no-echo mode that never reads stdin
|
||||||
|
# (shapeless) => exit 2 "could not confirm submission" (never delivered).
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||||
SEND="$HERE/send-message.sh"
|
SEND="${SEND:-$HERE/send-message.sh}"
|
||||||
SOCKET="verdict-test-$RANDOM-$$"
|
SOCKET="verdict-test-$RANDOM-$$"
|
||||||
TMP=$(mktemp -d)
|
TMP=$(mktemp -d)
|
||||||
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TMP"' EXIT
|
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TMP"' EXIT
|
||||||
@@ -44,19 +53,44 @@ else
|
|||||||
no "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e1")]"
|
no "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e1")]"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Fixture 2: NO prompt glyph (default bash PS1). THE regression: pre-patch this
|
# --- Fixture 2: NO prompt glyph, and the pane DOES submit (interactive bash).
|
||||||
# was a silent false-positive "delivered"; post-patch it must be unconfirmed→exit 2.
|
# RESHAPED 2026-08-16 (#1257), deliberately. This fixture previously asserted
|
||||||
|
# exit 2 here and was labelled "false-positive FIXED". That assertion was wrong,
|
||||||
|
# and locking it in is what kept E7 alive: the pane submits, so "delivered" is
|
||||||
|
# the truth, and a pi seat — whose input box is a bare U+2500 rule with no glyph
|
||||||
|
# — IS this fixture. Reporting exit 2 for it told operators a delivered message
|
||||||
|
# may be undelivered, and the retry that advice invites is the duplicate.
|
||||||
|
#
|
||||||
|
# The guard this fixture was reaching for is real and is NOT dropped: "never
|
||||||
|
# infer delivered from absence" is now enforced positively by fixture 2b below
|
||||||
|
# (glyphless AND not submitting => must fail) and by fixture 3 (locatable box
|
||||||
|
# still carrying our tail => draft). Absence alone decides nothing either way.
|
||||||
tmux -L "$SOCKET" new-session -d -s noglyph -c "$TMP" \
|
tmux -L "$SOCKET" new-session -d -s noglyph -c "$TMP" \
|
||||||
'PS1="sh-noglyph$ " exec bash --noprofile --norc -i'
|
'PS1="sh-noglyph$ " exec bash --noprofile --norc -i'
|
||||||
sleep 0.3
|
sleep 0.3
|
||||||
if out=$("$SEND" -L "$SOCKET" -t "=noglyph" -m "verdict fixture two must fail loud" 2>"$TMP/e2"); then
|
out=$("$SEND" -L "$SOCKET" -t "=noglyph" -m "verdict fixture two must fail loud" 2>"$TMP/e2"); rc=$?
|
||||||
no "unconfirmed: glyphless pane must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
|
||||||
|
ok "delivered: glyphless pane that submits => exit 0 (runtime-agnostic, E7 FIXED)"
|
||||||
|
else
|
||||||
|
no "delivered: glyphless pane that submits => exit 0" "rc=$rc out=[$out] err=[$(cat "$TMP/e2")]"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Fixture 2b: NO prompt glyph AND never submits — a hung managed TUI holding the
|
||||||
|
# terminal in raw/no-echo, which is what a stuck agent seat actually is (measured
|
||||||
|
# on live pi: stty -echo -icanon). Nothing is echoed, nothing is consumed, so
|
||||||
|
# there is no positive evidence of submission and the tool MUST fail loud. This
|
||||||
|
# is the historical false-positive guard, kept as a positive test.
|
||||||
|
tmux -L "$SOCKET" new-session -d -s rawstuck -c "$TMP" \
|
||||||
|
'bash --noprofile --norc -c "stty -echo -icanon min 1 time 0 2>/dev/null; exec sleep infinity"'
|
||||||
|
sleep 0.3
|
||||||
|
if out=$("$SEND" -L "$SOCKET" -t "=rawstuck" -r 1 -m "verdict fixture two-b never submitted" 2>"$TMP/e2b"); then
|
||||||
|
no "unconfirmed: glyphless hung TUI must NOT report success" "expected non-zero, got 0 (out=[$out])"
|
||||||
else
|
else
|
||||||
rc=$?
|
rc=$?
|
||||||
if [ "$rc" -eq 2 ] && grep -qF "could not confirm submission" "$TMP/e2"; then
|
if [ "$rc" -ne 0 ] && grep -qF "could not confirm submission" "$TMP/e2b"; then
|
||||||
ok "unconfirmed: glyphless pane => exit 2 + 'could not confirm submission' (false-positive FIXED)"
|
ok "unconfirmed: glyphless hung TUI (raw/no-echo) => non-zero + 'could not confirm submission'"
|
||||||
else
|
else
|
||||||
no "unconfirmed: glyphless pane => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e2")]"
|
no "unconfirmed: glyphless hung TUI => non-zero + stderr" "rc=$rc err=[$(cat "$TMP/e2b")]"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -126,6 +160,51 @@ else
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- Fixtures 6 and 6b: a SHAPELESS REPL. The pane renders nothing at all: no
|
||||||
|
# prompt glyph and no rule box, so locate_input_box() alone can never see it
|
||||||
|
# and shape-probing alone reports "may be UNDELIVERED" on a delivered message
|
||||||
|
# (measured live 2026-09-04, scratch probe: a shapeless consumer CONSUMED the
|
||||||
|
# message while the shipped shape probe exited 2 with retry advice - the exact
|
||||||
|
# #1257 regression). The cursor-row draft transition is the authoritative,
|
||||||
|
# runtime-agnostic delivered verdict: fixture 6's consumer submits => exit 0.
|
||||||
|
# Fixture 6b is the guard arm: a shapeless pane whose foreground never reads
|
||||||
|
# stdin keeps the echoed paste on the cursor line across every flush Enter =>
|
||||||
|
# DRAFT => exit 2, never delivered.
|
||||||
|
cat > "$TMP/shapeless.py" <<'SHAPELESS'
|
||||||
|
import sys
|
||||||
|
for line in sys.stdin:
|
||||||
|
pass # consume and render nothing
|
||||||
|
SHAPELESS
|
||||||
|
tmux -L "$SOCKET" new-session -d -s shapeless -c "$TMP" "exec python3 -u '$TMP/shapeless.py'"
|
||||||
|
sleep 0.3
|
||||||
|
out=$("$SEND" -L "$SOCKET" -t "=shapeless" -m "fixture six shapeless consumed ok" 2>"$TMP/e6"); rc=$?
|
||||||
|
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
|
||||||
|
ok "delivered: shapeless REPL that submits => exit 0 ✓ delivered (probe regression)"
|
||||||
|
else
|
||||||
|
no "delivered: shapeless REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e6")]"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# MEASURED LIMIT (2026-09-04, this suite's development): a shapeless pane in
|
||||||
|
# COOKED mode whose foreground never reads stdin (e.g. 'sleep infinity') scrolls
|
||||||
|
# its kernel echo off the cursor row on the flush Enter, so no runtime-agnostic
|
||||||
|
# signal available to the sender distinguishes it from a delivering pane. The
|
||||||
|
# non-reading guard therefore requires either a locatable box still carrying the
|
||||||
|
# tail (fixture 3) or raw/no-echo mode (fixture 2b). Real REPL seats read stdin,
|
||||||
|
# which is why this limit is not reachable against agent seats; recorded here so
|
||||||
|
# nobody rediscovers it as a silent gap.
|
||||||
|
tmux -L "$SOCKET" new-session -d -s shapelessraw -c "$TMP" 'stty raw -echo; exec sleep infinity'
|
||||||
|
sleep 0.3
|
||||||
|
if out=$("$SEND" -L "$SOCKET" -t "=shapelessraw" -r 1 -m "fixture six b shapeless raw never consumed" 2>"$TMP/e6b"); then
|
||||||
|
no "unconfirmed: shapeless raw non-reading pane must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||||
|
else
|
||||||
|
rc=$?
|
||||||
|
if [ "$rc" -eq 2 ] && grep -qF "could not confirm submission" "$TMP/e6b"; then
|
||||||
|
ok "unconfirmed: shapeless raw non-reading pane => exit 2 + 'could not confirm submission'"
|
||||||
|
else
|
||||||
|
no "unconfirmed: shapeless raw non-reading pane => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e6b")]"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo "---"
|
echo "---"
|
||||||
echo "PASS=$PASS FAIL=$FAIL"
|
echo "PASS=$PASS FAIL=$FAIL"
|
||||||
[ "$FAIL" -eq 0 ]
|
[ "$FAIL" -eq 0 ]
|
||||||
|
|||||||
Reference in New Issue
Block a user