hstack 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/VERSION +1 -1
- package/package.json +1 -1
- package/template/scripts/coord/coord_scan.py +121 -22
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to hstack are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [SemVer](https://semver.org/).
|
|
4
4
|
|
|
5
|
+
## [0.7.1] - 2026-07-25
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **Coord scan cost ~6 s per prompt — now ~50 ms when nothing changed (~120× on real repos).** First-day telemetry from `events.jsonl` (190 hook runs across 4 worktrees) showed the hook's branch walk costing median 5.9 s / p90 8.2 s per prompt — the ADR-0006 "sub-second" assumption was off by 10× on branch-heavy repos, and this ran on *every* prompt. This is the "cache keyed on ref state" mitigation ADR-0007 named. `coord_scan.py` now keeps a per-worktree cache at `hstack/.session-state/coord-scan-cache.json` keyed on a fingerprint of every source repo's local refs (plus identity, branch, horizon, and the current date, bounding cache life at one day). Messages only appear via commits and commits only move refs, so an unchanged fingerprint proves the walk would return the same set — the cache changes how fast, never what surfaces. Acks filter after collection and never invalidate; a corrupt or stale cache fails open to the full walk; deleting the file costs one re-walk. `scan`/`hook` telemetry events now carry `"cache": "hit"|"miss"` to keep the improvement measurable.
|
|
10
|
+
|
|
5
11
|
## [0.7.0] - 2026-07-24
|
|
6
12
|
|
|
7
13
|
Hook-driven coord notification (ADR-0007): sessions now learn about unread coord-messages automatically instead of waiting for the engineer to hand-carry a "check your messages" nudge between workspaces. Discovery stays pull-over-committed-state per ADR-0006 — the harness merely schedules the scan.
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.7.
|
|
1
|
+
0.7.1
|
package/package.json
CHANGED
|
@@ -35,6 +35,14 @@ addressing). The two local files this script touches are never authoritative:
|
|
|
35
35
|
hstack/.session-state/coord-cursor per-worktree acked-id list, shared by all
|
|
36
36
|
sessions in that worktree (derivative; losing
|
|
37
37
|
it re-surfaces messages — at-least-once)
|
|
38
|
+
hstack/.session-state/coord-scan-cache.json
|
|
39
|
+
per-worktree scan cache keyed on a refs-state
|
|
40
|
+
fingerprint (derivative; losing it costs one
|
|
41
|
+
full branch walk). Messages only appear via
|
|
42
|
+
commits and commits only move refs, so an
|
|
43
|
+
unchanged fingerprint proves the walk would
|
|
44
|
+
find the same set — the cache never changes
|
|
45
|
+
WHAT surfaces, only how fast.
|
|
38
46
|
hstack/.telemetry/coord/events.jsonl per-worktree usage log (gitignored via the
|
|
39
47
|
consumer's `**/.telemetry/` line; measurement
|
|
40
48
|
only, never authoritative, safe to delete)
|
|
@@ -46,6 +54,7 @@ No network calls. Reads git only via `git show` / `git ls-tree` /
|
|
|
46
54
|
from __future__ import annotations
|
|
47
55
|
|
|
48
56
|
import argparse
|
|
57
|
+
import hashlib
|
|
49
58
|
import json
|
|
50
59
|
import os
|
|
51
60
|
import re
|
|
@@ -59,6 +68,7 @@ from pathlib import Path
|
|
|
59
68
|
MESSAGES_DIR = "hstack/coord/messages"
|
|
60
69
|
NAME_RELPATH = "hstack/coord/NAME"
|
|
61
70
|
CURSOR_RELPATH = "hstack/.session-state/coord-cursor"
|
|
71
|
+
CACHE_RELPATH = "hstack/.session-state/coord-scan-cache.json"
|
|
62
72
|
TELEMETRY_RELPATH = "hstack/.telemetry/coord/events.jsonl"
|
|
63
73
|
DEFAULT_HORIZON_DAYS = 30
|
|
64
74
|
# Cursor entries older than twice the default horizon are pruned on ack.
|
|
@@ -279,13 +289,8 @@ def sanitize_ref(text: str, limit: int = 60) -> str:
|
|
|
279
289
|
return re.sub(r"[^A-Za-z0-9._/-]", "_", text)[:limit]
|
|
280
290
|
|
|
281
291
|
|
|
282
|
-
def
|
|
283
|
-
|
|
284
|
-
self_main: str,
|
|
285
|
-
current_branch: str,
|
|
286
|
-
horizon_days: int,
|
|
287
|
-
) -> list[dict[str, str]]:
|
|
288
|
-
"""Return unacked-agnostic candidate messages addressed to this repo."""
|
|
292
|
+
def resolve_sources(self_name: str, self_main: str) -> list[tuple[str, str]]:
|
|
293
|
+
"""Scan sources: this repo plus every reachable registered peer."""
|
|
289
294
|
registry = load_registry()
|
|
290
295
|
sources: list[tuple[str, str]] = [(self_name, self_main)]
|
|
291
296
|
self_real = os.path.realpath(self_main)
|
|
@@ -299,6 +304,19 @@ def collect_messages(
|
|
|
299
304
|
)
|
|
300
305
|
continue
|
|
301
306
|
sources.append((r["name"], r["path"]))
|
|
307
|
+
return sources
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def collect_messages(
|
|
311
|
+
self_name: str,
|
|
312
|
+
self_main: str,
|
|
313
|
+
current_branch: str,
|
|
314
|
+
horizon_days: int,
|
|
315
|
+
sources: list[tuple[str, str]] | None = None,
|
|
316
|
+
) -> list[dict[str, str]]:
|
|
317
|
+
"""Return unacked-agnostic candidate messages addressed to this repo."""
|
|
318
|
+
if sources is None:
|
|
319
|
+
sources = resolve_sources(self_name, self_main)
|
|
302
320
|
|
|
303
321
|
horizon = datetime.now() - timedelta(days=horizon_days)
|
|
304
322
|
seen_ids: set[str] = set()
|
|
@@ -370,6 +388,88 @@ def collect_messages(
|
|
|
370
388
|
return found
|
|
371
389
|
|
|
372
390
|
|
|
391
|
+
# -------------------------------------------------------- refs-state cache
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def scan_fingerprint(
|
|
395
|
+
sources: list[tuple[str, str]],
|
|
396
|
+
self_name: str,
|
|
397
|
+
current_branch: str,
|
|
398
|
+
horizon_days: int,
|
|
399
|
+
) -> str:
|
|
400
|
+
"""Fingerprint of everything the branch walk's result depends on.
|
|
401
|
+
|
|
402
|
+
Messages exist only as committed files, and commits only become visible
|
|
403
|
+
by moving a ref — so if no local ref of any source repo moved, the walk
|
|
404
|
+
would return byte-identical results. The remaining inputs (identity,
|
|
405
|
+
branch, horizon, and today's date for the expires/horizon filters) are
|
|
406
|
+
folded in; the date term bounds cache lifetime at one day.
|
|
407
|
+
|
|
408
|
+
The cursor (ack state) is deliberately NOT part of the fingerprint —
|
|
409
|
+
acked-filtering happens after collection, so acks never require a
|
|
410
|
+
re-walk.
|
|
411
|
+
"""
|
|
412
|
+
parts = [
|
|
413
|
+
"schema=1",
|
|
414
|
+
f"self={self_name}",
|
|
415
|
+
f"branch={current_branch}",
|
|
416
|
+
f"horizon={horizon_days}",
|
|
417
|
+
f"date={date.today().isoformat()}",
|
|
418
|
+
]
|
|
419
|
+
for name, path in sources:
|
|
420
|
+
refs = try_git(
|
|
421
|
+
["for-each-ref", "--format=%(refname:short) %(objectname)", "refs/heads"],
|
|
422
|
+
cwd=path,
|
|
423
|
+
)
|
|
424
|
+
parts.append(f"repo={name}:{os.path.realpath(path)}\n{refs or ''}")
|
|
425
|
+
return hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def collect_messages_cached(
|
|
429
|
+
root: str,
|
|
430
|
+
self_name: str,
|
|
431
|
+
self_main: str,
|
|
432
|
+
current_branch: str,
|
|
433
|
+
horizon_days: int,
|
|
434
|
+
) -> tuple[list[dict[str, str]], bool]:
|
|
435
|
+
"""collect_messages behind the refs-state cache.
|
|
436
|
+
|
|
437
|
+
Returns (messages, cache_hit). The cache is derivative in the strict
|
|
438
|
+
sense: deleting it costs one full branch walk and changes nothing else.
|
|
439
|
+
Corrupt or mismatched cache falls through to a full walk (fail open to
|
|
440
|
+
the slow-but-correct path).
|
|
441
|
+
"""
|
|
442
|
+
sources = resolve_sources(self_name, self_main)
|
|
443
|
+
fingerprint = scan_fingerprint(sources, self_name, current_branch, horizon_days)
|
|
444
|
+
cache_file = Path(root) / CACHE_RELPATH
|
|
445
|
+
try:
|
|
446
|
+
cached = json.loads(cache_file.read_text())
|
|
447
|
+
if (
|
|
448
|
+
cached.get("schema_version") == 1
|
|
449
|
+
and cached.get("fingerprint") == fingerprint
|
|
450
|
+
and isinstance(cached.get("messages"), list)
|
|
451
|
+
):
|
|
452
|
+
return cached["messages"], True
|
|
453
|
+
except Exception:
|
|
454
|
+
pass
|
|
455
|
+
found = collect_messages(
|
|
456
|
+
self_name, self_main, current_branch, horizon_days, sources=sources
|
|
457
|
+
)
|
|
458
|
+
try:
|
|
459
|
+
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
|
460
|
+
tmp = cache_file.with_suffix(".tmp")
|
|
461
|
+
tmp.write_text(
|
|
462
|
+
json.dumps(
|
|
463
|
+
{"schema_version": 1, "fingerprint": fingerprint, "messages": found},
|
|
464
|
+
ensure_ascii=False,
|
|
465
|
+
)
|
|
466
|
+
)
|
|
467
|
+
os.replace(tmp, cache_file) # atomic; concurrent scans race last-write-wins
|
|
468
|
+
except Exception:
|
|
469
|
+
pass # cache write failure only costs the next caller a full walk
|
|
470
|
+
return found, False
|
|
471
|
+
|
|
472
|
+
|
|
373
473
|
def cmd_scan(horizon_days: int) -> int:
|
|
374
474
|
started = time.monotonic()
|
|
375
475
|
root = repo_root()
|
|
@@ -378,16 +478,16 @@ def cmd_scan(horizon_days: int) -> int:
|
|
|
378
478
|
self_name = resolve_self_name(root, self_main, load_registry())
|
|
379
479
|
acked = load_acked(root)
|
|
380
480
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
]
|
|
481
|
+
found, cache_hit = collect_messages_cached(
|
|
482
|
+
root, self_name, self_main, current_branch, horizon_days
|
|
483
|
+
)
|
|
484
|
+
new = [m for m in found if m["id"] not in acked]
|
|
386
485
|
log_usage(
|
|
387
486
|
root,
|
|
388
487
|
"scan",
|
|
389
488
|
new_count=len(new),
|
|
390
489
|
duration_ms=int((time.monotonic() - started) * 1000),
|
|
490
|
+
cache="hit" if cache_hit else "miss",
|
|
391
491
|
)
|
|
392
492
|
if not new:
|
|
393
493
|
return 0 # silent — the zero-cost path
|
|
@@ -437,11 +537,10 @@ def cmd_hook(horizon_days: int) -> int:
|
|
|
437
537
|
current_branch = try_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=root) or "HEAD"
|
|
438
538
|
self_name = resolve_self_name(root, self_main, load_registry())
|
|
439
539
|
acked = load_acked(root)
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
]
|
|
540
|
+
found, cache_hit = collect_messages_cached(
|
|
541
|
+
root, self_name, self_main, current_branch, horizon_days
|
|
542
|
+
)
|
|
543
|
+
new = [m for m in found if m["id"] not in acked]
|
|
445
544
|
log_usage(
|
|
446
545
|
root,
|
|
447
546
|
"hook",
|
|
@@ -449,6 +548,7 @@ def cmd_hook(horizon_days: int) -> int:
|
|
|
449
548
|
session_id=session_id,
|
|
450
549
|
new_count=len(new),
|
|
451
550
|
duration_ms=int((time.monotonic() - started) * 1000),
|
|
551
|
+
cache="hit" if cache_hit else "miss",
|
|
452
552
|
)
|
|
453
553
|
if new:
|
|
454
554
|
print(
|
|
@@ -469,11 +569,10 @@ def cmd_ack(ids: list[str], ack_all: bool, horizon_days: int) -> int:
|
|
|
469
569
|
self_main = main_worktree(root)
|
|
470
570
|
current_branch = try_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=root) or "HEAD"
|
|
471
571
|
self_name = resolve_self_name(root, self_main, load_registry())
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
]
|
|
572
|
+
found, _ = collect_messages_cached(
|
|
573
|
+
root, self_name, self_main, current_branch, horizon_days
|
|
574
|
+
)
|
|
575
|
+
ids = [m["id"] for m in found if m["id"] not in acked]
|
|
477
576
|
if not ids:
|
|
478
577
|
print("hstack-coord: nothing to ack")
|
|
479
578
|
return 0
|