@appchy/jarvis 0.1.77 → 0.1.78

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.
@@ -384,12 +384,19 @@ def _results_out(data) -> list:
384
384
  for r in (data.get("results") or [])]
385
385
 
386
386
 
387
- def _verify_async(root, name) -> int:
388
- """Start the gates, or say where the run already in flight has got to.
389
-
390
- One call with three answers, because a verify takes minutes and a relayed tool
391
- call cannot. Which answer comes back is never the caller's to choose: it is
392
- whatever is true about this checkout right now.
387
+ def _verify_report(root, name) -> int:
388
+ """Say where the run stands. **Never starts one.**
389
+
390
+ ASKING AND STARTING ARE TWO OPERATIONS, and they used to be one call whose
391
+ meaning depended on timing: it attached when a run was in flight and started
392
+ one when none was. So a session checking whether the gates had finished set a
393
+ four-minute build going by asking — which is how a repo with a shared build
394
+ directory raced itself, and the resulting corruption was read as a flaky gate
395
+ for three sessions.
396
+
397
+ The split is on this side rather than the other because THIS is the call that
398
+ gets repeated. Polling is the loop; starting happens once and is said out
399
+ loud. A caller that repeats itself must be unable to cause anything.
393
400
  """
394
401
  running = live_run(root)
395
402
  if running:
@@ -424,6 +431,31 @@ def _verify_async(root, name) -> int:
424
431
  "done": done.get("done") or [], "passed": bool(done.get("passed")),
425
432
  "commit": done.get("sha") or "", "results": _results_out(done)})
426
433
 
434
+ return _answer(
435
+ "idle",
436
+ f"No gates have run for '{name or 'no task'}' at this commit. Start them "
437
+ f"explicitly — asking never starts a run.",
438
+ {"gates": sorted(VERIFY)})
439
+
440
+
441
+ def _verify_start(root, name) -> int:
442
+ """Start the gates in the background. **Never reports on somebody else's run.**
443
+
444
+ Refuses when a run is in flight rather than quietly attaching to it. Attaching
445
+ is what let one call report a run started for a DIFFERENT task, whose verdict
446
+ would then have been read against this one's commit — measured 2026-09-09.
447
+ A refusal that names the task holding the checkout is the honest answer, and
448
+ the report door is one call away.
449
+ """
450
+ running = live_run(root)
451
+ if running:
452
+ return _answer(
453
+ "running", _busy(running),
454
+ {"gate": running.get("current") or "starting",
455
+ "elapsedSeconds": _elapsed(running),
456
+ "gates": running.get("gates") or [], "done": running.get("done") or [],
457
+ "results": _results_out(running)})
458
+
427
459
  child = subprocess.Popen(
428
460
  [sys.executable, os.path.abspath(sys.argv[0]), "verify", "--task", name,
429
461
  "--detached"],
@@ -432,17 +464,24 @@ def _verify_async(root, name) -> int:
432
464
  return _answer(
433
465
  "started",
434
466
  f"Gates started ({len(VERIFY)}): {', '.join(sorted(VERIFY))}. They take "
435
- f"minutes, not seconds — ask again for progress, and again for the result.",
467
+ f"minutes, not seconds — ask for progress, which never starts another.",
436
468
  {"gates": sorted(VERIFY)})
437
469
 
438
470
 
439
471
  def cmd_verify(args) -> int:
440
472
  """Execute the verify commands and RECORD the result against the task.
441
473
 
442
- Three doors onto one run. Blocking is the person's and is unchanged. `--async`
443
- is the tool surface's, because a call that takes 174 seconds cannot be a relayed
444
- round trip. `--detached` is the child the second one starts and is nobody's to
445
- type.
474
+ Four doors, and **asking is not one of the ones that starts anything.**
475
+ Blocking is the person's: typing the command is the explicit act, so it runs
476
+ the gates and prints the table. `--async` starts a background run for the tool
477
+ surface, because a call that takes 174 seconds cannot be a relayed round trip.
478
+ `--status` only reports, and is what a caller waiting for a result uses.
479
+ `--detached` is the child `--async` spawns and is nobody's to type.
480
+
481
+ The two used to be one call that started or attached depending on timing, and
482
+ the ambiguity cost more than it saved: a session polling for a result started
483
+ the run it was waiting for, and one call attached to a run belonging to
484
+ another task and would have reported that verdict here.
446
485
  """
447
486
  root = find_work_root()
448
487
  name = (args.get("task") or "").strip()
@@ -456,16 +495,17 @@ def cmd_verify(args) -> int:
456
495
  _execute(root, name)
457
496
  return 0
458
497
 
498
+ if args.get("status"):
499
+ return _verify_report(root, name)
500
+
501
+ if args.get("async"):
502
+ return _verify_start(root, name)
503
+
459
504
  running = live_run(root)
460
505
  if running:
461
- if args.get("async"):
462
- return _verify_async(root, name)
463
506
  print(f"error: {_busy(running)}", file=sys.stderr)
464
507
  return 1
465
508
 
466
- if args.get("async"):
467
- return _verify_async(root, name)
468
-
469
509
  results, ok, sha = _execute(root, name)
470
510
  _print_report(results, sha)
471
511
  if name:
@@ -2799,6 +2799,83 @@ def test_a_verify_run_is_visible_to_the_next_caller():
2799
2799
  gate.VERIFY = old
2800
2800
 
2801
2801
 
2802
+ def _said(capsys_free_call) -> dict:
2803
+ """The JSON one of the async doors printed, as a dict."""
2804
+ import io
2805
+ from contextlib import redirect_stdout
2806
+
2807
+ buf = io.StringIO()
2808
+ with redirect_stdout(buf):
2809
+ capsys_free_call()
2810
+ for line in reversed(buf.getvalue().splitlines()):
2811
+ if line.strip().startswith("{"):
2812
+ return json.loads(line)
2813
+ raise AssertionError(f"no JSON on stdout: {buf.getvalue()!r}")
2814
+
2815
+
2816
+ def test_asking_where_the_gates_stand_never_starts_them():
2817
+ # Asking and starting used to be ONE call whose meaning depended on timing, so a
2818
+ # session polling for a result set off the four-minute run it was waiting for.
2819
+ # Polling is the loop and starting happens once, so the repeatable call is the
2820
+ # one that has to be unable to cause anything.
2821
+ with tempfile.TemporaryDirectory() as tmp:
2822
+ v = _tree(tmp)
2823
+ e = _epic(v, "an-epic")
2824
+ _task(e / "in-progress", "alpha", body="# T\n")
2825
+ with _work_dir(tmp) as root:
2826
+ old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
2827
+ try:
2828
+ out = _said(lambda: gate.cmd_verify({"task": "alpha", "status": True}))
2829
+ assert out["state"] == "idle", out
2830
+ # The real assertion: nothing ran, and nothing is running.
2831
+ assert gate.read_run(root) is None, "asking wrote a run file"
2832
+ assert gate.live_run(root) is None
2833
+ finally:
2834
+ gate.VERIFY = old
2835
+
2836
+
2837
+ def test_starting_while_a_run_is_live_names_whose_it_is_rather_than_attaching():
2838
+ # Attaching is what let one call report a run started for a DIFFERENT task,
2839
+ # whose verdict would then have been read against this one's commit.
2840
+ with tempfile.TemporaryDirectory() as tmp:
2841
+ v = _tree(tmp)
2842
+ e = _epic(v, "an-epic")
2843
+ _task(e / "in-progress", "alpha", body="# T\n")
2844
+ _task(e / "in-progress", "beta", body="# T\n")
2845
+ with _work_dir(tmp) as root:
2846
+ old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
2847
+ try:
2848
+ gate._write_run(root, {"task": "alpha", "pid": os.getpid(),
2849
+ "machine": "here", "started": gate._now().isoformat(),
2850
+ "gates": ["tests"], "current": "tests", "done": [],
2851
+ "finished": None, "read": False})
2852
+ out = _said(lambda: gate.cmd_verify({"task": "beta", "async": True}))
2853
+ assert out["state"] == "running", out
2854
+ assert "alpha" in out["message"], out["message"]
2855
+ finally:
2856
+ gate.VERIFY = old
2857
+
2858
+
2859
+ def test_a_finished_run_for_another_task_is_not_handed_over_as_this_ones():
2860
+ # The wrong-verdict half. A result proven for one item says nothing about
2861
+ # another, and reporting it would put a green gate on work nobody ran.
2862
+ with tempfile.TemporaryDirectory() as tmp:
2863
+ v = _tree(tmp)
2864
+ e = _epic(v, "an-epic")
2865
+ _task(e / "in-progress", "alpha", body="# T\n")
2866
+ _task(e / "in-progress", "beta", body="# T\n")
2867
+ with _work_dir(tmp) as root:
2868
+ old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
2869
+ try:
2870
+ gate.cmd_verify({"task": "alpha"})
2871
+ assert gate.read_run(root)["passed"] is True
2872
+ out = _said(lambda: gate.cmd_verify({"task": "beta", "status": True}))
2873
+ assert out["state"] == "idle", out
2874
+ assert out["passed"] is False, out
2875
+ finally:
2876
+ gate.VERIFY = old
2877
+
2878
+
2802
2879
  def test_a_second_verify_is_refused_while_one_is_running():
2803
2880
  with tempfile.TemporaryDirectory() as tmp:
2804
2881
  v = _tree(tmp)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appchy/jarvis",
3
- "version": "0.1.77",
3
+ "version": "0.1.78",
4
4
  "description": "Jarvis — local AI coding assistant CLI",
5
5
  "private": false,
6
6
  "type": "module",
@@ -57,16 +57,16 @@
57
57
  "typescript": "^5.7.0",
58
58
  "vitest": "^2.1.0",
59
59
  "@jarvis/agents": "1.0.0",
60
+ "@jarvis/anthropic": "1.0.0",
61
+ "@jarvis/board": "0.1.0",
60
62
  "@jarvis/data": "0.1.0",
61
63
  "@jarvis/errors": "1.0.0",
62
- "@jarvis/board": "0.1.0",
63
64
  "@jarvis/logger": "1.0.0",
64
65
  "@jarvis/rpc": "1.0.0",
65
66
  "@jarvis/types": "1.0.0",
66
67
  "@jarvis/typescript-config": "1.0.0",
67
- "@jarvis/ui": "0.1.0",
68
- "@jarvis/anthropic": "1.0.0",
69
- "@jarvis/vitest-config": "1.0.0"
68
+ "@jarvis/vitest-config": "1.0.0",
69
+ "@jarvis/ui": "0.1.0"
70
70
  },
71
71
  "scripts": {
72
72
  "dev": "tsx watch src/bin.ts start --foreground",