Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
7524c3f
Gitmoot implement adhoc-d02f3a58 (#14)
jerryfane Jul 20, 2026
bc61c8b
Gitmoot implement adhoc-24f4dc1f (#16)
jerryfane Jul 20, 2026
9a9f6fc
Gitmoot implement adhoc-32ac19e1 (#18)
jerryfane Jul 21, 2026
a53519e
Gitmoot implement adhoc-7e464142 (#20)
jerryfane Jul 21, 2026
89253c7
Gitmoot implement adhoc-d7645d7f (#21)
jerryfane Jul 21, 2026
ea838c6
fix: linker candidate matching tolerates prod turns missing stable_ke…
jerryfane Jul 21, 2026
17402f7
perf: skip sanitize/merge for byte-identical turn re-observations (#24)
jerryfane Jul 21, 2026
264fbf2
fix: resolve terminal-id send targets via agent.list when agent.get r…
jerryfane Jul 21, 2026
474e9ce
perf: gate submission-link sweep under legacy + de-sanitize settle pa…
jerryfane Jul 21, 2026
8ffff6d
Gitmoot implement adhoc-ab2a2a28 (#29)
jerryfane Jul 22, 2026
138d53c
Gitmoot implement adhoc-a9f360e1 (#30)
jerryfane Jul 22, 2026
6e4b254
fix: canonicalize pane identity at record construction (#31) (#32)
jerryfane Jul 23, 2026
14c4124
Gitmoot implement adhoc-e505765b (#34)
jerryfane Jul 23, 2026
98ce889
ci: skip sidecar benchmark tests when herdres root is absent (#36)
jerryfane Jul 23, 2026
3ac5536
ci: benchmark expects the delta-lane client sequence (#37)
jerryfane Jul 23, 2026
3296b39
Gitmoot merge adhoc-e505765b
jerryfane Jul 23, 2026
bbfbdb6
fix: harden herdr-compat edges found by upstream review (#35)
jerryfane Jul 23, 2026
67896c7
fix: port upstream 6975c51 refinements (terminal-ID convergence, narr…
jerryfane Jul 23, 2026
d65c02a
Gitmoot merge adhoc-187571fe
jerryfane Jul 23, 2026
a428eec
Gitmoot merge adhoc-537d3181
jerryfane Jul 23, 2026
ab25866
Gitmoot implement adhoc-1d4e592c
jerryfane Jul 24, 2026
45e382a
Gitmoot implement adhoc-571b908c
jerryfane Jul 24, 2026
5ca9d90
test: close leaked sqlite connections in retention tests; pin fresh-r…
jerryfane Jul 24, 2026
bdfe398
test: close the remaining net-new sqlite connections
jerryfane Jul 24, 2026
c153ef1
Merge pull request #47 from jerryfane/fix/converged-deploy-set
jerryfane Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ TENDWIRE_DB_PATH=~/.local/share/tendwire/tendwire.db
# authoritative resolution to the canonical public worker identity. Mutating
# request IDs must match [A-Za-z0-9._-]{1,128} exactly and round-trip without
# trimming, normalization, or case-folding. Requests are schema 1; exact command
# envelopes are schema 2, and their disposition—not status alone—is finality
# envelopes are schema 2 by default, and their disposition—not status alone—is finality
# authority. backend_unavailable may be no_receipt (not terminal authority) or
# terminal_rejected (persisted pre-send terminal rejection). A different request
# ID remains a distinct send even when instruction text is identical.
Expand All @@ -31,6 +31,13 @@ TENDWIRE_COMMAND_RECEIPT_RETENTION_COUNT=4096
# reserved plus accepted/rejected/uncertain rows. A bounded row is deleted only
# when outside both the age and newest-count floors.

# Every instruction send is recorded in the submission ledger. Source-identity
# observations settle closed 1:1 link components after this symmetric window
# ends. The link does not affect turn identity, delivery, or ordering.
# The hard TTL expires an unlinked row after one day and must cover the window.
TENDWIRE_SUBMISSION_LINK_WINDOW_SECONDS=60
TENDWIRE_SUBMISSION_HARD_TTL_SECONDS=86400

# Authoritative complete finals enter the neutral outbox before Herdres is
# online. A routable root payload has schema_version 2 and the exact public
# opaque stable_key plus integer stable_key_version=1 captured from persisted
Expand All @@ -50,7 +57,7 @@ TENDWIRE_ACKNOWLEDGED_FINAL_RETENTION_COUNT=4096
TENDWIRE_MAX_OUTBOX_ATTEMPTS=10
TENDWIRE_CONNECTOR_CLAIM_TTL_SECONDS=60
TENDWIRE_CONNECTOR_MAX_CLAIM_TTL_SECONDS=300
TENDWIRE_CONNECTOR_ACK_TTL_SECONDS=300
TENDWIRE_CONNECTOR_ACK_TTL_SECONDS=60

# Automatic snapshot cleanup defaults to 14 days and 4096 rows per host.
# Each host's latest row is always retained. At a 5-minute snapshot interval,
Expand Down Expand Up @@ -83,7 +90,8 @@ TENDWIRE_HERDR_TIMEOUT_SECONDS=1.0
# TENDWIRE_MAX_WORKERS. The internal refresh queue is fixed at 64.
TENDWIRE_TURN_REFRESH_INTERVAL_SECONDS=2.0
TENDWIRE_TURN_REFRESH_WORKERS=4
TENDWIRE_TURN_CLAIM_HARD_TTL_SECONDS=86400
# Compatibility flag: legacy|dual|shadow|observed all use the observed model.
TENDWIRE_TURN_MODEL=observed

# Process-local bounded LRU for repeated public-text sanitization. Set to 0 to
# disable; values above 65536 are capped. The default covers typical retained
Expand Down
4 changes: 0 additions & 4 deletions INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ Environment=TENDWIRE_HERDR_BACKEND=socket
Environment=TENDWIRE_DB_PATH=%h/.local/share/tendwire/tendwire.db
Environment=TENDWIRE_TURN_REFRESH_INTERVAL_SECONDS=2.0
Environment=TENDWIRE_TURN_REFRESH_WORKERS=4
Environment=TENDWIRE_TURN_CLAIM_HARD_TTL_SECONDS=86400
ExecStart=%h/.local/bin/tendwire daemon --db-path %h/.local/share/tendwire/tendwire.db
KillMode=control-group
KillSignal=SIGTERM
Expand Down Expand Up @@ -97,9 +96,6 @@ reconciles. `TENDWIRE_TURN_REFRESH_WORKERS` defaults to `4`, must be from 1
through 32, and cannot exceed `TENDWIRE_MAX_WORKERS`. Every adapter uses
`TENDWIRE_HERDR_TIMEOUT_SECONDS`; the queue is fixed at 64. One private target
is serialized with itself while distinct targets can use the worker pool.
Unobserved command-turn claims become terminal tombstones after
`TENDWIRE_TURN_CLAIM_HARD_TTL_SECONDS` (default `86400`).

OMP JSONL cache/IPC state is coordinate-only: parse/EOF and replay offsets,
observed file identity/size/timestamps, an open-turn flag, and validated project
root. It never contains or transports prompt IDs or user/final/stream bodies.
Expand Down
35 changes: 33 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,7 @@ variables:
| `max_outbox_attempts` | `TENDWIRE_MAX_OUTBOX_ATTEMPTS` | `10` | integer >= 1 |
| `connector_claim_ttl_seconds` | `TENDWIRE_CONNECTOR_CLAIM_TTL_SECONDS` | `60` | integer >= 1 |
| `connector_max_claim_ttl_seconds` | `TENDWIRE_CONNECTOR_MAX_CLAIM_TTL_SECONDS` | `300` | integer >= 1; caps `turn-final` poll and renew requests |
| `connector_ack_ttl_seconds` | `TENDWIRE_CONNECTOR_ACK_TTL_SECONDS` | `300` | integer >= 1; grace period for committed plans awaiting ACK completion |
| `connector_ack_ttl_seconds` | `TENDWIRE_CONNECTOR_ACK_TTL_SECONDS` | `60` | integer >= 1; grace period for committed plans awaiting ACK completion |
| `command_retry_horizon_seconds` | `TENDWIRE_COMMAND_RETRY_HORIZON_SECONDS` | `604800` | positive integer no greater than 604800 |
| `command_receipt_retention_seconds` | `TENDWIRE_COMMAND_RECEIPT_RETENTION_SECONDS` | `2592000` | integer at least 691200 and strictly greater than the retry horizon |
| `command_receipt_retention_count` | `TENDWIRE_COMMAND_RECEIPT_RETENTION_COUNT` | `4096` | positive integer; newest bounded inactive receipts per host |
Expand All @@ -547,7 +547,7 @@ variables:
| `store_maintenance_cadence_seconds` | `TENDWIRE_STORE_MAINTENANCE_CADENCE_SECONDS` | `3600` | positive integer |
| `turn_refresh_interval_seconds` | `TENDWIRE_TURN_REFRESH_INTERVAL_SECONDS` | `2.0` | finite positive float |
| `turn_refresh_workers` | `TENDWIRE_TURN_REFRESH_WORKERS` | `4` | integer from 1 through 32 and no greater than `max_workers` |
| `turn_claim_hard_ttl_seconds` | `TENDWIRE_TURN_CLAIM_HARD_TTL_SECONDS` | `86400` | positive integer; unobserved command claims become terminal after this interval |
| `turn_model` | `TENDWIRE_TURN_MODEL` | `observed` | `observed`; `legacy`, `dual`, and `shadow` are deprecated aliases with identical observed behavior |

The socket/event backend uses `event_debounce_seconds` for event batching and
`reconcile_interval_seconds` for bounded periodic full reconciles. Set
Expand Down Expand Up @@ -1405,6 +1405,9 @@ change is required.
least one non-empty explicit selector.
- `instruction` — optional; for `send_instruction` contains `text` only.
- `params` — optional opaque parameters.
- `response_schema_version` — optional response negotiation. Omit it (or use
`2`) for the unchanged schema-v2 envelope. A client may explicitly use `3`
to receive the accepted instruction submission handle described below.

Requests containing connector or low-level terminal fields are rejected before
any backend call. Rejected fields include `telegram`, `chat_id`, `topic_id`,
Expand Down Expand Up @@ -1439,6 +1442,34 @@ remains schema v1; for `command.submit`, its `result` is this exact schema-v2
command envelope. The CLI unwraps that result and prints the schema-v2 command
envelope itself.

For a non-dry-run `send_instruction`, a client can opt into schema v3 by
setting request `response_schema_version: 3`. Only an accepted response is
projected as v3; its existing `result.turn_id` remains unchanged and
`result.submission_id` is added as an opaque deterministic `twsub1.*` handle.
The default and all current Herdres requests remain byte-for-byte schema v2.
The durable receipt also remains schema v2, so response negotiation does not
change idempotency evidence.

Each instruction that reaches `send_started` is written atomically to the
`turn_submissions` ledger. Submission never creates a `turns` row; only a
source-derived observation can create a public turn. The ledger stores a
whitespace-normalized instruction digest, never another raw instruction copy,
and advances to `submitted` or `uncertain` with the terminal receipt transaction.
Unlinked rows expire after
`TENDWIRE_SUBMISSION_HARD_TTL_SECONDS` (default `86400`); candidate linkage uses
the symmetric observation window configured by
`TENDWIRE_SUBMISSION_LINK_WINDOW_SECONDS` (default `60`). Source-derived
observations settle this ledger only after the matching
window closes: a component links only when it contains exactly one submission
and one unlinked observation; larger components become `ambiguous`. Linkage is
metadata and does not change observation-derived turn identity, turn-list order,
Goal 10 delivery, or Herdres consumption. Lazy and periodic settlement cover
both submission-first and observation-first arrival order.

`TENDWIRE_TURN_MODEL` remains accepted for rollout compatibility. `legacy`,
`dual`, and `shadow` emit a warning and use the same observation-authoritative
behavior as `observed`.

`disposition`, not `status` alone, is the receipt-authority and finality
contract:

Expand Down
87 changes: 87 additions & 0 deletions docs/evidence/outbox-delivery-lane.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Connector outbox delivery-lane recovery

## ACK deadline and ordered recovery

`final_ready` sources enter `awaiting_ack` only after their presentation plan is
committed. The outbox now stores the bounded ACK deadline in both the private
state and `next_attempt_at`. The daemon's one-second periodic connector sweep
therefore sees the deadline without waiting for another consumer poll.

When the deadline expires, the sweep:

1. leaves a still-valid leased part alone;
2. fails the abandoned presentation generation;
3. retains delivered prefix evidence;
4. moves the source to `retry` with exponential backoff capped at 30 seconds;
5. keeps the same source `delivery_key` and ordering key.

A legacy or restart-orphaned `awaiting_ack` row without any deadline is treated
as already overdue. It cannot remain a non-terminal row without a future sweep.
Malformed-only ACK or lease deadline state is handled the same way; when one
stored deadline is malformed, a second valid persisted deadline still wins.
Likewise, a malformed retry/defer availability timestamp is treated as due
rather than permanently future.
The retrying source remains the lane head, so a later final on the same ordering
key cannot pass it during backoff.

Redelivery is safe at the consumer boundary. Herdres
`herdres_connector/state.py` stores turn-final receipts by the stable outbox job
key in `find_tendwire_turn_job` and reserves the immutable intent
idempotently in `reserve_tendwire_turn_job`; transient lease refs are explicitly
not ledger keys. The consumer checkpoints `telegram_applied` before ACK.
Herdres regression coverage includes
`test_provider_acceptance_crash_persists_reserved_and_restart_never_resends`
and
`test_restart_reconciles_committed_last_part_ack_without_turn_list_or_resend`
in `tests/test_turn_final_delivery.py`.

## Drain health

Tendwire's daemon reclaim cadence is one second and reclaim is not pass-capped.
The Tendwire poll boundary accepts up to 100 rows, and a regression test leases
100 independent due ordering lanes in one pass before the 30-second target.

The production consumer inspected alongside this change runs
`herdres sync --loop 5`; its `SyncRuntime.max_sends` default is 8, shared with
ordinary turn sends, and `_drain_turn_final` polls one job at a time. That
consumer budget can take more than 60 seconds for a 100-item backlog even when
all ordering keys are unblocked. Tendwire health now reports:

- due pollable rows;
- the oldest due timestamp (retry/defer age starts when the row becomes due);
- overdue `awaiting_ack` rows;
- a `starved` flag after 30 seconds.

A starved outbox degrades daemon health instead of allowing hours of silent
queue age. The consumer-side budget/cadence must also be raised in Herdres to
meet the end-to-end target; Tendwire cannot make the external Telegram consumer
perform more physical operations.

## Dead-letter autopsy and recovery boundary

A read-only production snapshot taken after the reported 648-row count had 658
cumulative dead-letter rows:

| Structural class | Rows | Latest recorded outcome |
| --- | ---: | --- |
| Failed presentation-plan parts | 429 | 375 retry scheduled, 32 missing, 15 deferred, 7 expired |
| Retryable final anchors | 130 | 117 deferred, 13 attempts exhausted |
| Historical migration holds | 98 | no delivery attempt |
| Other generic work | 1 | expired |

The count was cumulative, not "created today": 424 of those rows were updated
on 2026-07-23 and 8 on 2026-07-24 at inspection time.

Failed plan parts are audit evidence and must not be resurrected independently;
doing so could reorder a presentation suffix. Their recovery is the source
retry or explicit failed-plan recovery, which creates a new ordered generation.
Final anchors and eligible migration holds use the existing bounded
`connector.retry` operation, one exact delivery key/final identity at a time.
That operation revalidates the authoritative current revision and returns
`stale_revision` or `not_retryable` instead of reviving unsafe history.

`connector.inspect` now returns structural classification counts and the
allowed recovery boundary. Tendwire also retains the consumer's safe,
enumerated turn-final reason codes instead of collapsing all future failures to
`unknown`, so subsequent autopsies can distinguish routing churn, revision
conflicts, rate limiting, and delivery uncertainty.
41 changes: 29 additions & 12 deletions scripts/sqlite_sidecar_race_benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -1039,23 +1039,40 @@ def _run_herdres_phase(
os.environ.clear()
os.environ.update(environment)
records = [json.loads(line) for line in call_log.read_text(encoding="utf-8").splitlines()]
def _normalized_client_call(argv: list[str]) -> list[str]:
# Watermark tokens are run-specific; validate their shape, then
# normalize so the sequence stays exactly comparable.
normalized = list(argv)
for index, value in enumerate(normalized):
if (
index > 0
and normalized[index - 1] == "--watermark"
and value.startswith("twdelta1.")
):
normalized[index] = "<WATERMARK>"
return normalized

expected_commands = []
for _ in range(3):
for pass_index in range(3):
delta_call = [
"--socket-path",
str(socket_path),
"turn",
"delta",
"--json",
"--limit",
"500",
]
if pass_index > 0:
delta_call.extend(("--watermark", "<WATERMARK>"))
expected_commands.extend(
(["--socket-path", str(socket_path), "snapshot", "--json"],
[
"--socket-path",
str(socket_path),
"turns",
"--schema-version",
"2",
"--limit",
str(turn_page_limit),
"--json",
],
delta_call,
["--socket-path", str(socket_path), "pending", "--json"])
)
commands_exact = records == expected_commands
commands_exact = [
_normalized_client_call(record) for record in records
] == expected_commands
noop_results = results[1:]
return {
"mode": "source",
Expand Down
7 changes: 5 additions & 2 deletions scripts/turn_ingestion_benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,12 +375,15 @@ def _seed_store(
store.save_snapshot(db_path, snapshot)
if store.upsert_worker_bindings(db_path, bindings) != len(bindings):
raise RuntimeError("binding_seed_failed")
for binding in bindings:
for ordinal, binding in enumerate(bindings):
applied = store.apply_turn_refresh(
db_path,
FIXTURE_HOST,
binding.worker_id,
content,
{
**content,
"source_turn_id": f"synthetic-source-turn-{ordinal}",
},
expected_binding=binding,
observed_at=FIXTURE_TIMESTAMP,
)
Expand Down
Loading