@team-agent/installer 0.5.0 → 0.5.2

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.
Files changed (40) hide show
  1. package/Cargo.lock +118 -9
  2. package/Cargo.toml +2 -2
  3. package/crates/team-agent/Cargo.toml +1 -0
  4. package/crates/team-agent/src/app_server_test_support.rs +258 -0
  5. package/crates/team-agent/src/cli/adapters.rs +32 -3
  6. package/crates/team-agent/src/cli/attach_app_server_leader.rs +16 -0
  7. package/crates/team-agent/src/cli/diagnose.rs +14 -5
  8. package/crates/team-agent/src/cli/emit.rs +111 -3
  9. package/crates/team-agent/src/cli/mod.rs +72 -25
  10. package/crates/team-agent/src/cli/named_address.rs +975 -0
  11. package/crates/team-agent/src/cli/send.rs +200 -1
  12. package/crates/team-agent/src/cli/status_port.rs +44 -6
  13. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  14. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  15. package/crates/team-agent/src/cli/tests/named_address.rs +586 -0
  16. package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -0
  17. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  18. package/crates/team-agent/src/cli/types.rs +21 -0
  19. package/crates/team-agent/src/codex_app_server.rs +488 -0
  20. package/crates/team-agent/src/conpty/backend.rs +710 -0
  21. package/crates/team-agent/src/conpty/mod.rs +32 -0
  22. package/crates/team-agent/src/coordinator/backoff.rs +45 -6
  23. package/crates/team-agent/src/diagnose/orphans.rs +11 -3
  24. package/crates/team-agent/src/leader/lease.rs +144 -7
  25. package/crates/team-agent/src/leader/owner_bind.rs +5 -2
  26. package/crates/team-agent/src/leader/start.rs +18 -5
  27. package/crates/team-agent/src/leader/tests/lease_api.rs +172 -0
  28. package/crates/team-agent/src/lib.rs +23 -0
  29. package/crates/team-agent/src/lifecycle/launch.rs +127 -3
  30. package/crates/team-agent/src/lifecycle/restart/common.rs +70 -0
  31. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +7 -0
  32. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +90 -0
  33. package/crates/team-agent/src/messaging/delivery.rs +329 -9
  34. package/crates/team-agent/src/messaging/leader_receiver.rs +17 -138
  35. package/crates/team-agent/src/messaging/mod.rs +2 -2
  36. package/crates/team-agent/src/messaging/results.rs +78 -21
  37. package/crates/team-agent/src/messaging/tests/runtime.rs +232 -0
  38. package/crates/team-agent/src/tmux_backend.rs +31 -0
  39. package/crates/team-agent/src/transport_factory.rs +632 -0
  40. package/package.json +4 -4
@@ -2835,16 +2835,91 @@ pub fn quick_start_in_workspace_with_display(
2835
2835
  yes: bool,
2836
2836
  team_id: Option<&str>,
2837
2837
  open_display: bool,
2838
+ ) -> Result<QuickStartReport, LifecycleError> {
2839
+ // Legacy entrypoint: no `--backend` override. Preserves the exact
2840
+ // tmux behavior we shipped in Phase 1c (byte-equivalent tmux path
2841
+ // when no explicit backend is asked for).
2842
+ quick_start_in_workspace_with_display_and_backend(
2843
+ workspace,
2844
+ agents_dir,
2845
+ name,
2846
+ yes,
2847
+ team_id,
2848
+ open_display,
2849
+ None,
2850
+ )
2851
+ }
2852
+
2853
+ /// 0.5.x Phase 1d Batch 2: quick-start with an optional
2854
+ /// `--backend <tmux|conpty>` override. When `backend` is `None` or
2855
+ /// `Some("tmux")` the transport is the same one the legacy entrypoint
2856
+ /// built (byte-equivalent to Phase 1c), so tmux users see no
2857
+ /// behavioral change.
2858
+ ///
2859
+ /// When `backend` is `Some("conpty")`, this call routes through the
2860
+ /// factory. On a host without a live shim client (i.e. every
2861
+ /// non-Windows host today), the resulting `ConPtyBackend` degrades
2862
+ /// its spawn/inject/capture calls to `TransportError::MuxUnavailable`
2863
+ /// honestly — MUST-NOT-13 + CR C-1 ①. Users see a real "conpty
2864
+ /// unavailable" error rather than a silent tmux fallback.
2865
+ pub fn quick_start_in_workspace_with_display_and_backend(
2866
+ workspace: &Path,
2867
+ agents_dir: &Path,
2868
+ name: Option<&str>,
2869
+ yes: bool,
2870
+ team_id: Option<&str>,
2871
+ open_display: bool,
2872
+ backend: Option<&str>,
2838
2873
  ) -> Result<QuickStartReport, LifecycleError> {
2839
2874
  let workspace = explicit_quick_start_workspace(workspace);
2840
- let transport = quick_start_tmux_backend(&workspace);
2875
+ // Default / `tmux` literal: preserve the existing tmux path
2876
+ // BYTE-FOR-BYTE. This is the CR §Batch 2 Verification anchor:
2877
+ // `quick-start` without `--backend` produces byte-equivalent tmux
2878
+ // behavior.
2879
+ let literal = backend.map(str::trim);
2880
+ let is_tmux_or_default = matches!(literal, None | Some("") | Some("tmux") | Some("TMUX"));
2881
+ if is_tmux_or_default {
2882
+ let transport = quick_start_tmux_backend(&workspace);
2883
+ return quick_start_with_transport_in_workspace_with_display(
2884
+ &workspace,
2885
+ agents_dir,
2886
+ name,
2887
+ yes,
2888
+ team_id,
2889
+ &transport,
2890
+ open_display,
2891
+ );
2892
+ }
2893
+ // Explicit non-tmux backend: route through the factory. Parse the
2894
+ // literal, then delegate. On unsupported literals or refused
2895
+ // preconditions we return `LifecycleError` — never silently pick
2896
+ // tmux (CR C-1 ①/②).
2897
+ let requested = crate::transport_factory::RequestedTransportBackend::parse_literal(
2898
+ literal.unwrap_or_default(),
2899
+ )
2900
+ .ok_or_else(|| {
2901
+ LifecycleError::TeamSelect(format!(
2902
+ "unsupported --backend literal {literal:?}; expected `tmux` or `conpty` \
2903
+ (Phase 1d does not auto-map `pty` to conpty — CR C-1 ②)"
2904
+ ))
2905
+ })?;
2906
+ let team_key_for_factory = team_id;
2907
+ let input = crate::transport_factory::TransportFactoryInput::new(
2908
+ &workspace,
2909
+ crate::transport_factory::TransportPurpose::Launch,
2910
+ )
2911
+ .with_team_key(team_key_for_factory)
2912
+ .with_explicit_backend(Some(requested));
2913
+ let resolved = crate::transport_factory::resolve_transport(input)
2914
+ .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
2915
+ // Hand the boxed backend down as `&dyn Transport`.
2841
2916
  quick_start_with_transport_in_workspace_with_display(
2842
2917
  &workspace,
2843
2918
  agents_dir,
2844
2919
  name,
2845
2920
  yes,
2846
2921
  team_id,
2847
- &transport,
2922
+ &*resolved.backend,
2848
2923
  open_display,
2849
2924
  )
2850
2925
  }
@@ -3068,7 +3143,16 @@ pub fn quick_start_with_transport_in_workspace_with_display(
3068
3143
  let resolved_spec_path =
3069
3144
  std::fs::canonicalize(&spec_path).unwrap_or_else(|_| spec_path.clone());
3070
3145
  let mut state = initial_runtime_state(&spec, &resolved_spec_path, &workspace, agents_dir);
3071
- annotate_runtime_tmux_endpoint(&mut state, transport, &workspace);
3146
+ // 0.5.x Phase 1d hot-path 接线(裁决1 msg_76e1d98202b8): use the
3147
+ // generic annotator that writes `state.transport = { kind, source }`
3148
+ // for every backend AND (for tmux) preserves the existing
3149
+ // `tmux_endpoint`/`tmux_socket`/`tmux_socket_source` fields via the
3150
+ // inner `annotate_runtime_tmux_endpoint` call. Source is threaded
3151
+ // from the caller-side factory selection when available; the
3152
+ // legacy quick-start entrypoint currently passes `None` (defaults
3153
+ // to "unknown" in the payload) — Phase 2 refactor will thread
3154
+ // ResolvedTransport.source through the launch signature.
3155
+ annotate_runtime_transport(&mut state, transport, &workspace, None);
3072
3156
  save_launched_team_state_for_key(&workspace, &state, Some(&state_team_key))?;
3073
3157
  annotate_persisted_team_depth(
3074
3158
  &workspace,
@@ -3164,6 +3248,46 @@ pub fn quick_start_with_transport_in_workspace_with_display(
3164
3248
  /// the persisted endpoint synchronized with the transport they actually used,
3165
3249
  /// closing the silent socket-drift gap** (single state-save path; no parallel
3166
3250
  /// "annotate after spawn" race with coordinator).
3251
+ /// 0.5.x Phase 1d Batch 2: generic runtime-transport annotator.
3252
+ ///
3253
+ /// Writes `state.transport = { kind, source }` for every backend
3254
+ /// (kind = wire string `"tmux"` | `"conpty"`; source = wire string
3255
+ /// from `ResolvedTransport.source` when known, else `"unknown"`).
3256
+ ///
3257
+ /// For tmux, ALSO forwards to `annotate_runtime_tmux_endpoint` so the
3258
+ /// existing `tmux_endpoint` / `tmux_socket` / `tmux_socket_source`
3259
+ /// fields remain populated byte-equivalent to today's shape.
3260
+ ///
3261
+ /// The tmux-specific fields are NOT written for ConPTY (CR C-4: no
3262
+ /// tmux-only fields under a conpty state; keeps compact status
3263
+ /// stable). ConPTY discovery fields (`pipe_name`, `shim_pid`) are
3264
+ /// added in Batch 3 when the shim boot path lands; this function just
3265
+ /// pins the top-level `transport.kind`/`source`.
3266
+ pub fn annotate_runtime_transport(
3267
+ state: &mut serde_json::Value,
3268
+ transport: &dyn Transport,
3269
+ workspace: &Path,
3270
+ source: Option<&str>,
3271
+ ) {
3272
+ use crate::transport::BackendKind;
3273
+ let kind_wire = match transport.kind() {
3274
+ BackendKind::Tmux => "tmux",
3275
+ BackendKind::WezTerm => "wezterm",
3276
+ BackendKind::ConPty => "conpty",
3277
+ };
3278
+ if let Some(obj) = state.as_object_mut() {
3279
+ let transport_block = serde_json::json!({
3280
+ "kind": kind_wire,
3281
+ "source": source.unwrap_or("unknown"),
3282
+ });
3283
+ obj.insert("transport".to_string(), transport_block);
3284
+ }
3285
+ // Tmux: preserve the existing tmux endpoint annotation shape.
3286
+ if matches!(transport.kind(), BackendKind::Tmux) {
3287
+ annotate_runtime_tmux_endpoint(state, transport, workspace);
3288
+ }
3289
+ }
3290
+
3167
3291
  pub(crate) fn annotate_runtime_tmux_endpoint(
3168
3292
  state: &mut serde_json::Value,
3169
3293
  transport: &dyn Transport,
@@ -467,6 +467,22 @@ pub(super) fn start_coordinator_for_workspace(workspace: &Path) -> Result<bool,
467
467
  /// only within `lifecycle::restart`. Sharing the resolver across the lifecycle
468
468
  /// module is the correct ownership: restart/add/fork all need the SAME socket
469
469
  /// the live team uses, and duplicating the lookup invited drift.**
470
+ /// 0.5.x Phase 1d Batch 1: this legacy resolver stays tmux-only. It
471
+ /// still returns a concrete `TmuxBackend` because the 6 caller sites
472
+ /// (rebuild/agent/remove/launch) currently call `TmuxBackend`-specific
473
+ /// methods (`.tmux_endpoint()`, `.for_workspace()`, etc.). But it now
474
+ /// routes through the factory so its **selection semantics** match
475
+ /// the new `lifecycle_worker_transport_for_selected_state` API:
476
+ ///
477
+ /// - If `state.transport.kind` is `"conpty"`, this legacy tmux-typed
478
+ /// resolver **fails-closed** with `TransportBackendKindMismatch`
479
+ /// rather than silently returning a tmux backend that would
480
+ /// spawn/inject into the wrong pane universe. That satisfies CR C-1
481
+ /// fail-closed while the migration of caller sites to the generic
482
+ /// `_transport_for_selected_state` API completes.
483
+ /// - Otherwise (default/legacy tmux endpoint), behavior is preserved
484
+ /// byte-for-byte through
485
+ /// `tmux_backend_for_runtime_state_or_workspace`.
470
486
  pub(crate) fn lifecycle_worker_tmux_backend_for_selected_state(
471
487
  run_workspace: &Path,
472
488
  team: Option<&str>,
@@ -485,12 +501,66 @@ pub(crate) fn lifecycle_worker_tmux_backend_for_selected_state(
485
501
  .unwrap_or("");
486
502
  return Err(LifecycleError::TeamSelect(format!("{reason}: {detail}")));
487
503
  }
504
+ // CR C-1 fail-closed: state with `transport.kind=conpty` must NOT
505
+ // silently downgrade to a tmux backend just because the caller
506
+ // asked for the tmux-typed variant. Callers that see this refusal
507
+ // are expected to migrate to `lifecycle_worker_transport_for_selected_state`
508
+ // during Batch 2/3.
509
+ if let Some(state_ref) = state.as_ref() {
510
+ if let Some(kind) = state_ref.pointer("/transport/kind").and_then(|v| v.as_str()) {
511
+ if kind.eq_ignore_ascii_case("conpty") {
512
+ return Err(LifecycleError::TeamSelect(format!(
513
+ "backend_kind_mismatch: state.transport.kind={kind:?} but the legacy \
514
+ tmux-typed lifecycle resolver was invoked; caller must migrate to \
515
+ `lifecycle_worker_transport_for_selected_state` (Phase 1d Batch 2/3)"
516
+ )));
517
+ }
518
+ }
519
+ }
488
520
  Ok(state
489
521
  .as_ref()
490
522
  .map(|state| lifecycle_worker_tmux_backend_for_state(run_workspace, state))
491
523
  .unwrap_or_else(|| crate::tmux_backend::TmuxBackend::for_workspace(run_workspace)))
492
524
  }
493
525
 
526
+ /// 0.5.x Phase 1d Batch 1: new generic-typed lifecycle resolver.
527
+ ///
528
+ /// Same team-selection semantics as the legacy tmux-typed variant, but
529
+ /// returns a `ResolvedTransport` from the factory so callers that can
530
+ /// hold `Box<dyn Transport>` (or the resolved `BackendKind`) work
531
+ /// against both tmux AND conpty state. Caller migration to this API
532
+ /// happens across Batch 2-5.
533
+ ///
534
+ /// Fail-closed rules follow the factory (C-1 ×2, C-2 CLI-vs-state,
535
+ /// C-3 N38 notices via `ResolvedTransport.notices`).
536
+ pub(crate) fn lifecycle_worker_transport_for_selected_state(
537
+ run_workspace: &Path,
538
+ team: Option<&str>,
539
+ ) -> Result<crate::transport_factory::ResolvedTransport, LifecycleError> {
540
+ let (state, refusal) = crate::state::projection::resolve_team_scoped_state(run_workspace, team)
541
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
542
+ if let Some(refusal) = refusal {
543
+ let reason = refusal
544
+ .get("reason")
545
+ .and_then(|v| v.as_str())
546
+ .unwrap_or("team_target_unresolved");
547
+ let detail = refusal
548
+ .get("error")
549
+ .or_else(|| refusal.get("message"))
550
+ .and_then(|v| v.as_str())
551
+ .unwrap_or("");
552
+ return Err(LifecycleError::TeamSelect(format!("{reason}: {detail}")));
553
+ }
554
+ let input = crate::transport_factory::TransportFactoryInput::new(
555
+ run_workspace,
556
+ crate::transport_factory::TransportPurpose::LifecycleWorker,
557
+ )
558
+ .with_team_key(team)
559
+ .with_state(state.as_ref());
560
+ crate::transport_factory::resolve_transport(input)
561
+ .map_err(|e| LifecycleError::TeamSelect(e.to_string()))
562
+ }
563
+
494
564
  pub(super) fn lifecycle_worker_tmux_backend_for_state(
495
565
  run_workspace: &Path,
496
566
  state: &serde_json::Value,
@@ -2693,6 +2693,13 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
2693
2693
  "tasks",
2694
2694
  "display_backend",
2695
2695
  "is_external_leader",
2696
+ // 0.5.x Phase 1d hot-path 接线 (裁决1 msg_76e1d98202b8):
2697
+ // `transport = { kind, source }` inserted here by
2698
+ // `annotate_runtime_transport`. Kind is `"tmux"` for the
2699
+ // default tmux path (byte-equivalent behavior + new metadata
2700
+ // field); source is `"unknown"` until Phase 2 threads
2701
+ // `ResolvedTransport.source` through the launch call site.
2702
+ "transport",
2696
2703
  // 0.4.0 refactor: `team_key` added as top-level topology marker
2697
2704
  // alongside active_team_key (refactor-modular-architecture). The
2698
2705
  // owner / leader_receiver / owner_epoch invariants below still
@@ -11,6 +11,20 @@ use std::collections::{BTreeMap, BTreeSet};
11
11
  use std::path::{Path, PathBuf};
12
12
 
13
13
  const ANCESTRY_ENV: &str = "TEAM_AGENT_TEST_PROCESS_ANCESTRY_ARGV_JSON";
14
+ const CALLER_IDENTITY_ENVS: &[&str] = &[
15
+ "TMUX",
16
+ "TMUX_PANE",
17
+ "TEAM_AGENT_LEADER_PANE_ID",
18
+ "TEAM_AGENT_LEADER_SESSION_UUID",
19
+ "TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE",
20
+ "TEAM_AGENT_LEADER_PROVIDER",
21
+ "TEAM_AGENT_MACHINE_FINGERPRINT",
22
+ "TEAM_AGENT_WORKSPACE",
23
+ "TEAM_AGENT_TEAM_ID",
24
+ "TEAM_AGENT_OWNER_TEAM_ID",
25
+ "TEAM_AGENT_ACTIVE_TEAM",
26
+ "TEAM_AGENT_ID",
27
+ ];
14
28
 
15
29
  #[test]
16
30
  #[serial_test::serial(env)]
@@ -141,6 +155,10 @@ struct PhaseGolden {
141
155
 
142
156
  fn run_phase_golden(spec: PhaseGolden) -> Value {
143
157
  let _permission_mode = EnvVarGuard::set(ANCESTRY_ENV, "[]");
158
+ let _caller_identity = CALLER_IDENTITY_ENVS
159
+ .iter()
160
+ .map(|key| EnvVarGuard::unset(key))
161
+ .collect::<Vec<_>>();
144
162
  let team = two_worker_team_dir();
145
163
  let workspace = team.parent().expect("workspace").to_path_buf();
146
164
  seed_healthy_coordinator(&workspace);
@@ -186,6 +204,7 @@ fn run_phase_golden(spec: PhaseGolden) -> Value {
186
204
  json: true,
187
205
  message_id: Some("phase-golden-message".to_string()),
188
206
  pane: None,
207
+ to_name: None,
189
208
  });
190
209
  let collect = cmd_collect(&CollectArgs {
191
210
  workspace: workspace.clone(),
@@ -394,6 +413,14 @@ impl EnvVarGuard {
394
413
  }
395
414
  Self { key, previous }
396
415
  }
416
+
417
+ fn unset(key: &'static str) -> Self {
418
+ let previous = std::env::var(key).ok();
419
+ unsafe {
420
+ std::env::remove_var(key);
421
+ }
422
+ Self { key, previous }
423
+ }
397
424
  }
398
425
 
399
426
  impl Drop for EnvVarGuard {
@@ -531,6 +558,15 @@ fn normalize_path_string(text: &str, ctx: &NormalizeCtx) -> String {
531
558
 
532
559
  fn normalize_team_agent_binary_path(text: &str) -> String {
533
560
  let mut out = text.to_string();
561
+ // 0.5.0 hermetic 教训「环境路径类 token 化」的直接延伸
562
+ // (leader msg_6ee04cf5aee8):归一化改为结构判据,不绑路径前缀。
563
+ // 用户 CARGO_TARGET_DIR 可以指到任意目录(默认 `<repo>/target`,
564
+ // 全局改道时是 `/Volumes/nvme/cargo-target`,CI 时可能是别的)。
565
+ // 结构判据:任意绝对路径下的 `/deps/team_agent-<hex>`(可选 `.exe`)
566
+ // → `<TEAM_AGENT_BIN>`。同时保留旧 marker 兼容(处理旧固定字符串
567
+ // 出现在 output 里,以及 `debug/team-agent` / `release/team-agent`
568
+ // 这两个非 `/deps/` 分支)。
569
+ out = normalize_deps_team_agent_by_structure(&out);
534
570
  for marker in [
535
571
  "/target/debug/deps/team_agent-",
536
572
  "/target/debug/team-agent",
@@ -541,6 +577,60 @@ fn normalize_team_agent_binary_path(text: &str) -> String {
541
577
  out
542
578
  }
543
579
 
580
+ /// Structural (path-prefix-independent) normalization for
581
+ /// `/…/deps/team_agent-<hex>(.exe)?` occurrences. Scans the input for
582
+ /// every `/deps/team_agent-` marker and rewrites the containing
583
+ /// absolute path token to `<TEAM_AGENT_BIN>`, no matter which
584
+ /// `CARGO_TARGET_DIR` produced it.
585
+ fn normalize_deps_team_agent_by_structure(text: &str) -> String {
586
+ const MARKER: &str = "/deps/team_agent-";
587
+ let mut out = String::with_capacity(text.len());
588
+ let bytes = text.as_bytes();
589
+ let mut i = 0usize;
590
+ while i < bytes.len() {
591
+ let Some(rel) = text[i..].find(MARKER) else {
592
+ out.push_str(&text[i..]);
593
+ break;
594
+ };
595
+ let marker_idx = i + rel;
596
+ // Locate the start of the absolute path token: walk back from
597
+ // `marker_idx` while the byte is not one of a small set of
598
+ // delimiters (quote, whitespace, `[`, `(`, `,`, `:` after
599
+ // whitespace). The absolute-path anchor MUST also start with
600
+ // `/` — if the containing token is not absolute, skip
601
+ // rewriting so we don't eat unrelated text.
602
+ let path_start = text[..marker_idx]
603
+ .rfind(|c: char| matches!(c, '"' | '\'' | ' ' | '[' | '(' | ',' | '\n' | '\t'))
604
+ .map(|idx| idx + 1)
605
+ .unwrap_or(0);
606
+ // Confirm the token starts with `/` (absolute path).
607
+ if !text[path_start..].starts_with('/') {
608
+ // Not an absolute path — leave alone.
609
+ out.push_str(&text[i..marker_idx + MARKER.len()]);
610
+ i = marker_idx + MARKER.len();
611
+ continue;
612
+ }
613
+ // Extend the end past the hex hash + optional `.exe`. Same
614
+ // continuation rule as `replace_path_with_marker`: alnum + `-`
615
+ // + `_` + `.`.
616
+ let mut path_end = marker_idx + MARKER.len();
617
+ while path_end < text.len() {
618
+ let ch = text[path_end..].chars().next().expect("char at boundary");
619
+ if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
620
+ path_end += ch.len_utf8();
621
+ } else {
622
+ break;
623
+ }
624
+ }
625
+ // Emit everything up to the path start, then the token, then
626
+ // continue past the eaten span.
627
+ out.push_str(&text[i..path_start]);
628
+ out.push_str("<TEAM_AGENT_BIN>");
629
+ i = path_end;
630
+ }
631
+ out
632
+ }
633
+
544
634
  fn replace_path_with_marker(mut text: String, marker: &str, token: &str) -> String {
545
635
  let mut search_from = 0;
546
636
  while let Some(offset) = text[search_from..].find(marker) {