@rallycry/conveyor-agent 10.2.2 → 10.2.4

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.
@@ -0,0 +1,47 @@
1
+ import {
2
+ lagFromBuffer,
3
+ statusFromBuffer
4
+ } from "./chunk-7TQO4ZF4.js";
5
+
6
+ // src/connection/heartbeat-worker.ts
7
+ import { parentPort, workerData } from "worker_threads";
8
+ import { io } from "socket.io-client";
9
+ var data = workerData;
10
+ var view = new Float64Array(data.sharedBuffer);
11
+ var taskToken = data.taskToken;
12
+ var socket = io(data.apiUrl, {
13
+ auth: (cb) => cb({ taskToken, runnerMode: data.runnerMode }),
14
+ transports: ["websocket"],
15
+ reconnection: true,
16
+ reconnectionAttempts: Infinity,
17
+ reconnectionDelay: 2e3,
18
+ reconnectionDelayMax: 3e4,
19
+ randomizationFactor: 0.3,
20
+ extraHeaders: { "ngrok-skip-browser-warning": "true" }
21
+ });
22
+ parentPort?.on("message", (msg) => {
23
+ if (msg?.taskToken) taskToken = msg.taskToken;
24
+ });
25
+ socket.on("disconnect", (reason) => {
26
+ if (reason === "io server disconnect" || reason === "server namespace disconnect") {
27
+ socket.connect();
28
+ }
29
+ });
30
+ function beat() {
31
+ if (!socket.connected) return;
32
+ socket.emit(
33
+ "agentSessionService:heartbeat",
34
+ {
35
+ sessionId: data.sessionId,
36
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
37
+ status: statusFromBuffer(view),
38
+ loopLagMs: Math.round(lagFromBuffer(view))
39
+ },
40
+ // Ack intentionally ignored: a missed beat self-heals on the next tick,
41
+ // and the worker must never accumulate state or throw.
42
+ () => {
43
+ }
44
+ );
45
+ }
46
+ setInterval(beat, data.intervalMs);
47
+ //# sourceMappingURL=heartbeat-worker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/connection/heartbeat-worker.ts"],"sourcesContent":["/**\n * Starvation-proof heartbeat: a worker thread with its OWN event loop and its\n * OWN Socket.IO connection, so a stalled/starved main loop cannot silence the\n * session's liveness signal.\n *\n * Why this exists: the v3 lease chain (heartbeat → renewSessionLease, TTL\n * 150s + 120s stranded grace) reaps a session whose agent goes quiet for\n * ~4.5 minutes. A main-loop stall during a heavy gate used to do exactly\n * that — the session was declared stranded and the restart tore down the\n * in-flight run. This worker keeps beating through such stalls, reporting\n * `loopLagMs` (staleness of the main loop's shared tick) so the API can tell\n * \"alive but busy\" (renew the lease) from \"wedged\" (stop renewing past the\n * wedge ceiling and let the normal recovery run). The main loop still sends\n * its richer heartbeat when healthy — duplicate renewals are idempotent.\n *\n * Deliberately minimal: no bootstrap token refresh (the main thread posts\n * rotated tokens via postMessage), no reconnect ceremony beyond Socket.IO's\n * built-in retry, and it never throws — a broken worker just means heartbeats\n * degrade to main-loop-only, which is yesterday's behavior.\n */\nimport { parentPort, workerData } from \"node:worker_threads\";\nimport { io } from \"socket.io-client\";\nimport { lagFromBuffer, statusFromBuffer } from \"./loop-lag.js\";\n\ninterface HeartbeatWorkerData {\n apiUrl: string;\n taskToken: string;\n sessionId: string;\n runnerMode: string;\n sharedBuffer: SharedArrayBuffer;\n intervalMs: number;\n}\n\nconst data = workerData as HeartbeatWorkerData;\nconst view = new Float64Array(data.sharedBuffer);\nlet taskToken = data.taskToken;\n\nconst socket = io(data.apiUrl, {\n auth: (cb) => cb({ taskToken, runnerMode: data.runnerMode }),\n transports: [\"websocket\"],\n reconnection: true,\n reconnectionAttempts: Infinity,\n reconnectionDelay: 2000,\n reconnectionDelayMax: 30000,\n randomizationFactor: 0.3,\n extraHeaders: { \"ngrok-skip-browser-warning\": \"true\" },\n});\n\n// The main thread posts rotated task tokens (bootstrap refresh) so the worker\n// socket survives token expiry. The next (re)connect picks it up via the auth\n// callback above; a server-initiated disconnect needs a manual nudge.\nparentPort?.on(\"message\", (msg: { taskToken?: string }) => {\n if (msg?.taskToken) taskToken = msg.taskToken;\n});\n\nsocket.on(\"disconnect\", (reason: string) => {\n if (reason === \"io server disconnect\" || reason === \"server namespace disconnect\") {\n socket.connect();\n }\n});\n\nfunction beat(): void {\n if (!socket.connected) return;\n socket.emit(\n \"agentSessionService:heartbeat\",\n {\n sessionId: data.sessionId,\n timestamp: new Date().toISOString(),\n status: statusFromBuffer(view),\n loopLagMs: Math.round(lagFromBuffer(view)),\n },\n // Ack intentionally ignored: a missed beat self-heals on the next tick,\n // and the worker must never accumulate state or throw.\n () => {},\n );\n}\n\nsetInterval(beat, data.intervalMs);\n"],"mappings":";;;;;;AAoBA,SAAS,YAAY,kBAAkB;AACvC,SAAS,UAAU;AAYnB,IAAM,OAAO;AACb,IAAM,OAAO,IAAI,aAAa,KAAK,YAAY;AAC/C,IAAI,YAAY,KAAK;AAErB,IAAM,SAAS,GAAG,KAAK,QAAQ;AAAA,EAC7B,MAAM,CAAC,OAAO,GAAG,EAAE,WAAW,YAAY,KAAK,WAAW,CAAC;AAAA,EAC3D,YAAY,CAAC,WAAW;AAAA,EACxB,cAAc;AAAA,EACd,sBAAsB;AAAA,EACtB,mBAAmB;AAAA,EACnB,sBAAsB;AAAA,EACtB,qBAAqB;AAAA,EACrB,cAAc,EAAE,8BAA8B,OAAO;AACvD,CAAC;AAKD,YAAY,GAAG,WAAW,CAAC,QAAgC;AACzD,MAAI,KAAK,UAAW,aAAY,IAAI;AACtC,CAAC;AAED,OAAO,GAAG,cAAc,CAAC,WAAmB;AAC1C,MAAI,WAAW,0BAA0B,WAAW,+BAA+B;AACjF,WAAO,QAAQ;AAAA,EACjB;AACF,CAAC;AAED,SAAS,OAAa;AACpB,MAAI,CAAC,OAAO,UAAW;AACvB,SAAO;AAAA,IACL;AAAA,IACA;AAAA,MACE,WAAW,KAAK;AAAA,MAChB,YAAW,oBAAI,KAAK,GAAE,YAAY;AAAA,MAClC,QAAQ,iBAAiB,IAAI;AAAA,MAC7B,WAAW,KAAK,MAAM,cAAc,IAAI,CAAC;AAAA,IAC3C;AAAA;AAAA;AAAA,IAGA,MAAM;AAAA,IAAC;AAAA,EACT;AACF;AAEA,YAAY,MAAM,KAAK,UAAU;","names":[]}
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as _project_shared from '@project/shared';
2
- import { RunnerMode, AgentSessionServiceMethods, AgentMode, WorkspaceDiscoveredPort, AgentQuestion, AgentRunnerStatus, BootstrapBundle } from '@project/shared';
2
+ import { RunnerMode, AgentSessionServiceMethods, AgentMode, PtyChatEventPayload, WorkspaceDiscoveredPort, AgentQuestion, AgentRunnerStatus, BootstrapBundle } from '@project/shared';
3
3
  export * from '@project/shared';
4
4
  import { ChildProcess } from 'node:child_process';
5
5
 
@@ -143,6 +143,12 @@ declare class AgentConnection {
143
143
  cols: number;
144
144
  rows: number;
145
145
  }): void;
146
+ /**
147
+ * Forward one compact chat-proxy event derived from the transcript JSONL to
148
+ * the relay (fire-and-forget). Feeds the experimental chat PTY proxy ring.
149
+ * Old servers that don't know the method reject harmlessly.
150
+ */
151
+ sendPtyChatEvent(event: PtyChatEventPayload): void;
146
152
  /**
147
153
  * Report that the interactive CLI process for this session has died and no
148
154
  * respawn is imminent (fire-and-forget). The server clears the scrollback
@@ -164,7 +170,10 @@ declare class AgentConnection {
164
170
  duplicate: true;
165
171
  matchedMessagePreview: string;
166
172
  };
167
- sendHeartbeat(): void;
173
+ sendHeartbeat(loopLagMs?: number): void;
174
+ private heartbeatWorker;
175
+ startHeartbeatWorker(sharedBuffer: SharedArrayBuffer, intervalMs?: number): void;
176
+ stopHeartbeatWorker(): void;
168
177
  emitModeChanged(agentMode?: AgentMode | null): void;
169
178
  updateTaskFields(fields: {
170
179
  plan?: string;
@@ -409,8 +418,14 @@ declare class SessionRunner {
409
418
  private agentSpokeThisTurn;
410
419
  /** Guards overlapping runs of the periodic git flush. */
411
420
  private periodicFlushInFlight;
421
+ /** Consecutive flush ticks skipped because a heavy gate was running. */
422
+ private gateSkipCount;
423
+ /** Max consecutive gate-deferred ticks (~2 min each) before flushing anyway. */
424
+ private static readonly MAX_GATE_SKIPS;
412
425
  /** Runtime preview-port poller (v3 dynamic port discovery). */
413
426
  private portDiscovery;
427
+ /** Main event-loop lag measurement, shared with the heartbeat worker. */
428
+ private readonly loopLag;
414
429
  constructor(config: SessionRunnerConfig, callbacks: SessionRunnerCallbacks);
415
430
  get state(): AgentRunnerStatus;
416
431
  get sessionId(): string;
@@ -508,16 +523,27 @@ declare class SessionRunner {
508
523
  stop(): void;
509
524
  softStop(): void;
510
525
  private static readonly MAX_STUCK_NUDGES;
511
- /** The prompt delivered into the live TUI when an auto agent looks stuck. It
512
- * must offer BOTH escape routes so the agent never just goes idle. */
526
+ /** The build-phase nudge (In Progress, no PR). Offers BOTH escape routes so
527
+ * the agent never just goes idle. */
513
528
  private static readonly STUCK_PROMPT;
529
+ /** The planning-phase nudge (still Planning, identification/plan unfinished).
530
+ * Same two-escape-route shape, aimed at finishing discovery instead of a PR. */
531
+ private static readonly STUCK_PROMPT_PLANNING;
514
532
  /**
515
- * An auto task is "stuck" when its last turn ended (the call sites run
516
- * post-turn, so the TUI is no longer actively working), it has no open PR,
517
- * AND the agent did not communicate in chat this turn. If the agent posted a
518
- * message (explained itself / asked for help) it is waiting on a human, not
519
- * silently stuck, so we leave it attached instead of nudging.
533
+ * Classify how an auto task is "stuck" after its turn ended (call sites run
534
+ * post-turn, so the TUI is no longer working), or null if it isn't. Shared
535
+ * guards: only auto, not rate-limited, under the nudge cap, and the agent
536
+ * stayed silent this turn if it posted to chat (explained itself / asked for
537
+ * help) it's waiting on a human, not silently stuck.
538
+ *
539
+ * - "pr": In Progress with no open PR (build finished without shipping).
540
+ * - "planning": still Planning without (identified + saved plan). A fresh auto
541
+ * agent that finishes its plan turn WITHOUT ExitPlanMode would otherwise idle
542
+ * → clean-exit → its session Ends → the workspace is reaped "agent_gone"
543
+ * before a plan ever landed. Nudging (and, on exhaustion, going dormant)
544
+ * keeps the session Active so the pod is never prematurely slept.
520
545
  */
546
+ private autoStuckKind;
521
547
  private isAutoStuck;
522
548
  /**
523
549
  * Stop driving the agent and route the core loop into dormant idle —
@@ -528,15 +554,18 @@ declare class SessionRunner {
528
554
  */
529
555
  private enterDormantForHumanTakeover;
530
556
  private refreshTaskContext;
557
+ /** Prompt + chat-message label for a stuck-nudge of the given kind. */
558
+ private stuckNudgeContent;
531
559
  private maybeHandleStuckAuto;
532
560
  private buildFullContext;
533
561
  private createQueryBridge;
534
562
  private wireConnectionCallbacks;
535
563
  /** Eager finalize snapshot triggered by the reconciler's sleep signal
536
- * (session:finalizeSnapshot). Runs pg_dump + a full capture and uploads it
564
+ * (session:finalizeSnapshot). Runs a full workspace capture and uploads it
537
565
  * NOW so the sleep confirms on this snapshot instead of waiting for the
538
- * ~2min periodic flush and so sidecar DB state (which ONLY finalizeForSleep
539
- * captures via pg_dump) actually lands. Best-effort: on failure the
566
+ * ~2min periodic flush. Sidecar DB state is intentionally NOT captured it
567
+ * is no longer durable across sleep/wake (#2623); only the agent's
568
+ * /workspace rides the snapshot. Best-effort: on failure the
540
569
  * periodic/shutdown path stays the fallback. No-op on non-v3 pods. */
541
570
  private finalizeSnapshotNow;
542
571
  /** Proactively refresh the GitHub token before the 1-hour expiry. */
@@ -569,29 +598,16 @@ interface AgentRunnerCallbacks {
569
598
  onStatusChange: (status: string) => void | Promise<void>;
570
599
  }
571
600
 
572
- declare class PlanSync {
573
- private planFileSnapshot;
574
- private lockedPlanFile;
575
- private workspaceDir;
576
- private connection;
577
- constructor(workspaceDir: string, connection: AgentConnection);
578
- updateWorkspaceDir(workspaceDir: string): void;
579
- private getPlanDirs;
580
- snapshotPlanFiles(): void;
581
- private findNewestPlanFile;
582
- syncPlanFile(): Promise<void>;
583
- }
584
-
585
601
  /** Returns true if the working tree has uncommitted changes (staged or unstaged). */
586
- declare function hasUncommittedChanges(cwd: string): boolean;
602
+ declare function hasUncommittedChanges(cwd: string): Promise<boolean>;
587
603
  /** Returns the current branch name, or null if on detached HEAD. */
588
- declare function getCurrentBranch(cwd: string): string | null;
604
+ declare function getCurrentBranch(cwd: string): Promise<string | null>;
589
605
  /** Returns true if there are committed changes that haven't been pushed to origin. */
590
- declare function hasUnpushedCommits(cwd: string): boolean;
606
+ declare function hasUnpushedCommits(cwd: string): Promise<boolean>;
591
607
  /** Stage all changes and create a commit. Returns the commit hash if successful. */
592
- declare function stageAndCommit(cwd: string, message: string): string | null;
608
+ declare function stageAndCommit(cwd: string, message: string): Promise<string | null>;
593
609
  /** Update the git remote URL with a fresh token. */
594
- declare function updateRemoteToken(cwd: string, token: string): void;
610
+ declare function updateRemoteToken(cwd: string, token: string): Promise<void>;
595
611
  /** Best-effort flush of pending work: pushes any real (intentional) commits to
596
612
  * the task branch, and captures uncommitted changes as a WIP snapshot on the
597
613
  * `conveyor-wip/<branch>` ref — never as commits on the branch itself. When
@@ -608,18 +624,18 @@ declare function flushPendingChanges(cwd: string, opts?: {
608
624
  * attempting the push. On auth failure, refreshes again and retries. */
609
625
  declare function pushToOrigin(cwd: string, refreshToken?: () => Promise<string | undefined>, skipVerify?: boolean): Promise<boolean>;
610
626
 
611
- declare function ensureWorktree(projectDir: string, taskId: string, branch?: string): string;
627
+ declare function ensureWorktree(projectDir: string, taskId: string, branch?: string): Promise<string>;
612
628
  /**
613
629
  * Detach any task worktree that has `branch` checked out, releasing the branch lock
614
630
  * so the main project directory can check it out. Only touches worktrees inside
615
631
  * `.worktrees/` — never the main project directory.
616
632
  */
617
- declare function detachWorktreeBranch(projectDir: string, branch: string): void;
633
+ declare function detachWorktreeBranch(projectDir: string, branch: string): Promise<void>;
618
634
  /**
619
635
  * Force-remove is intentional: this runs after task completion or explicit stop.
620
636
  * Any uncommitted changes at this point are scratch artifacts, not valuable work.
621
637
  */
622
- declare function removeWorktree(projectDir: string, taskId: string): void;
638
+ declare function removeWorktree(projectDir: string, taskId: string): Promise<void>;
623
639
 
624
640
  interface CaptureContext {
625
641
  cwd: string;
@@ -627,9 +643,6 @@ interface CaptureContext {
627
643
  snapshotUploadUrl?: string;
628
644
  refreshToken?: () => Promise<string | undefined>;
629
645
  wipMessage?: string;
630
- /** Path to a pre-built postgres dump OUTSIDE cwd (see pg-dump.ts) that the
631
- * GCS tar should carry under its fixed in-archive name. */
632
- pgDumpPath?: string;
633
646
  /** Force the GCS PUT even when the tar is empty (sizeBytes === 0). Used by the
634
647
  * sleep FINALIZE path so `snapshotAt` is stamped and the reconciler's
635
648
  * teardown confirm lands immediately — an empty workspace is a valid (empty)
@@ -644,8 +657,6 @@ interface SnapshotArtifact {
644
657
  sizeBytes: number;
645
658
  gcsUploaded: boolean;
646
659
  wipPushed: boolean;
647
- /** Whether this capture bundled a postgres dump into the GCS artifact. */
648
- pgDumped: boolean;
649
660
  }
650
661
  interface RestoreResult {
651
662
  source: "gcs" | "wip" | "clean";
@@ -656,7 +667,6 @@ interface GcsUploadResult {
656
667
  fileCount: number;
657
668
  deletionCount: number;
658
669
  sizeBytes: number;
659
- pgDumped: boolean;
660
670
  }
661
671
  /**
662
672
  * The GCS sink on its own: assemble the snapshot tar (the single capture
@@ -667,7 +677,6 @@ interface GcsUploadResult {
667
677
  * after any earlier in-flight one has settled.
668
678
  */
669
679
  declare function uploadSnapshotToGcs(cwd: string, uploadUrl: string | undefined, opts?: {
670
- pgDumpPath?: string;
671
680
  forceUpload?: boolean;
672
681
  }): Promise<GcsUploadResult>;
673
682
  declare function captureSnapshot(ctx: CaptureContext): Promise<SnapshotArtifact>;
@@ -692,7 +701,6 @@ declare function restoreOnBoot(bundle: Pick<BootstrapBundle, "snapshotUrl"> & {
692
701
  interface ConveyorConfig {
693
702
  setupCommand?: string;
694
703
  startCommand?: string;
695
- previewPort?: number;
696
704
  }
697
705
  interface ForwardPortsResult {
698
706
  ports: number[];
@@ -712,4 +720,4 @@ declare function runStartCommand(cmd: string, cwd: string, onOutput: (stream: "s
712
720
  /** Fetch full git history if the repo was cloned with --depth=1. */
713
721
  declare function unshallowRepo(workspaceDir: string): void;
714
722
 
715
- export { AgentConnection, type AgentConnectionConfig, type AgentRunnerCallbacks, type AgentRunnerConfig, type ApiKeyUpdateData, type CaptureContext, type ConveyorConfig, type GcsUploadResult, type IncomingMessage, type LifecycleCallbacks, type LifecycleConfig, type ModeAction, type ModeTaskContext, PlanSync, type RestoreResult, SessionRunner, type SessionRunnerCallbacks, type SessionRunnerConfig, type SetModeData, type SnapshotArtifact, captureSnapshot, detachWorktreeBranch, ensureWorktree, finalizeForSleep, flushPendingChanges, getCurrentBranch, hasUncommittedChanges, hasUnpushedCommits, loadConveyorConfig, loadForwardPorts, pushToOrigin, removeWorktree, restoreOnBoot, runAuthTokenCommand, runSetupCommand, runStartCommand, stageAndCommit, startPeriodic as startWorkPreservation, stop as stopWorkPreservation, unshallowRepo, updateRemoteToken, uploadSnapshotToGcs };
723
+ export { AgentConnection, type AgentConnectionConfig, type AgentRunnerCallbacks, type AgentRunnerConfig, type ApiKeyUpdateData, type CaptureContext, type ConveyorConfig, type GcsUploadResult, type IncomingMessage, type LifecycleCallbacks, type LifecycleConfig, type ModeAction, type ModeTaskContext, type RestoreResult, SessionRunner, type SessionRunnerCallbacks, type SessionRunnerConfig, type SetModeData, type SnapshotArtifact, captureSnapshot, detachWorktreeBranch, ensureWorktree, finalizeForSleep, flushPendingChanges, getCurrentBranch, hasUncommittedChanges, hasUnpushedCommits, loadConveyorConfig, loadForwardPorts, pushToOrigin, removeWorktree, restoreOnBoot, runAuthTokenCommand, runSetupCommand, runStartCommand, stageAndCommit, startPeriodic as startWorkPreservation, stop as stopWorkPreservation, unshallowRepo, updateRemoteToken, uploadSnapshotToGcs };
package/dist/index.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  AgentConnection,
3
- PlanSync,
4
3
  SessionRunner,
5
4
  captureSnapshot,
6
5
  finalizeForSleep,
@@ -21,46 +20,45 @@ import {
21
20
  unshallowRepo,
22
21
  updateRemoteToken,
23
22
  uploadSnapshotToGcs
24
- } from "./chunk-53ZLFIAI.js";
23
+ } from "./chunk-DEMRCBJN.js";
24
+ import "./chunk-7TQO4ZF4.js";
25
25
 
26
26
  // src/runner/worktree.ts
27
- import { execSync } from "child_process";
27
+ import { execFile } from "child_process";
28
28
  import { existsSync } from "fs";
29
29
  import { join } from "path";
30
+ import { promisify } from "util";
31
+ var execFileAsync = promisify(execFile);
32
+ var GIT_TIMEOUT_MS = 6e4;
33
+ async function git(cwd, args) {
34
+ const { stdout } = await execFileAsync("git", args, { cwd, timeout: GIT_TIMEOUT_MS });
35
+ return stdout.toString();
36
+ }
30
37
  var WORKTREE_DIR = ".worktrees";
31
- function ensureWorktree(projectDir, taskId, branch) {
38
+ async function ensureWorktree(projectDir, taskId, branch) {
32
39
  if (projectDir.includes(`/${WORKTREE_DIR}/`)) {
33
40
  return projectDir;
34
41
  }
35
42
  const worktreePath = join(projectDir, WORKTREE_DIR, taskId);
36
43
  if (existsSync(worktreePath)) {
37
44
  if (branch) {
38
- if (hasUncommittedChanges(worktreePath)) {
45
+ if (await hasUncommittedChanges(worktreePath)) {
39
46
  return worktreePath;
40
47
  }
41
48
  try {
42
- execSync(`git checkout --detach origin/${branch}`, {
43
- cwd: worktreePath,
44
- stdio: "ignore"
45
- });
49
+ await git(worktreePath, ["checkout", "--detach", `origin/${branch}`]);
46
50
  } catch {
47
51
  }
48
52
  }
49
53
  return worktreePath;
50
54
  }
51
55
  const ref = branch ? `origin/${branch}` : "HEAD";
52
- execSync(`git worktree add --detach "${worktreePath}" ${ref}`, {
53
- cwd: projectDir,
54
- stdio: "ignore"
55
- });
56
+ await git(projectDir, ["worktree", "add", "--detach", worktreePath, ref]);
56
57
  return worktreePath;
57
58
  }
58
- function detachWorktreeBranch(projectDir, branch) {
59
+ async function detachWorktreeBranch(projectDir, branch) {
59
60
  try {
60
- const output = execSync("git worktree list --porcelain", {
61
- cwd: projectDir,
62
- encoding: "utf-8"
63
- });
61
+ const output = await git(projectDir, ["worktree", "list", "--porcelain"]);
64
62
  const entries = output.split("\n\n");
65
63
  for (const entry of entries) {
66
64
  const lines = entry.trim().split("\n");
@@ -70,27 +68,23 @@ function detachWorktreeBranch(projectDir, branch) {
70
68
  const worktreePath = worktreeLine.replace("worktree ", "");
71
69
  if (!worktreePath.includes(`/${WORKTREE_DIR}/`)) continue;
72
70
  try {
73
- execSync("git checkout --detach", { cwd: worktreePath, stdio: "ignore" });
71
+ await git(worktreePath, ["checkout", "--detach"]);
74
72
  } catch {
75
73
  }
76
74
  }
77
75
  } catch {
78
76
  }
79
77
  }
80
- function removeWorktree(projectDir, taskId) {
78
+ async function removeWorktree(projectDir, taskId) {
81
79
  const worktreePath = join(projectDir, WORKTREE_DIR, taskId);
82
80
  if (!existsSync(worktreePath)) return;
83
81
  try {
84
- execSync(`git worktree remove "${worktreePath}" --force`, {
85
- cwd: projectDir,
86
- stdio: "ignore"
87
- });
82
+ await git(projectDir, ["worktree", "remove", worktreePath, "--force"]);
88
83
  } catch {
89
84
  }
90
85
  }
91
86
  export {
92
87
  AgentConnection,
93
- PlanSync,
94
88
  SessionRunner,
95
89
  captureSnapshot,
96
90
  detachWorktreeBranch,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/runner/worktree.ts"],"sourcesContent":["import { execSync } from \"node:child_process\";\nimport { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { hasUncommittedChanges } from \"./git-utils.js\";\n\nconst WORKTREE_DIR = \".worktrees\";\n\nexport function ensureWorktree(projectDir: string, taskId: string, branch?: string): string {\n if (projectDir.includes(`/${WORKTREE_DIR}/`)) {\n return projectDir;\n }\n\n const worktreePath = join(projectDir, WORKTREE_DIR, taskId);\n\n if (existsSync(worktreePath)) {\n if (branch) {\n if (hasUncommittedChanges(worktreePath)) {\n return worktreePath;\n }\n try {\n execSync(`git checkout --detach origin/${branch}`, {\n cwd: worktreePath,\n stdio: \"ignore\",\n });\n } catch {\n /* branch doesn't exist on remote yet */\n }\n }\n return worktreePath;\n }\n\n const ref = branch ? `origin/${branch}` : \"HEAD\";\n execSync(`git worktree add --detach \"${worktreePath}\" ${ref}`, {\n cwd: projectDir,\n stdio: \"ignore\",\n });\n\n return worktreePath;\n}\n\n/**\n * Detach any task worktree that has `branch` checked out, releasing the branch lock\n * so the main project directory can check it out. Only touches worktrees inside\n * `.worktrees/` — never the main project directory.\n */\nexport function detachWorktreeBranch(projectDir: string, branch: string): void {\n try {\n const output = execSync(\"git worktree list --porcelain\", {\n cwd: projectDir,\n encoding: \"utf-8\",\n });\n const entries = output.split(\"\\n\\n\");\n for (const entry of entries) {\n const lines = entry.trim().split(\"\\n\");\n const worktreeLine = lines.find((l) => l.startsWith(\"worktree \"));\n const branchLine = lines.find((l) => l.startsWith(\"branch \"));\n if (!worktreeLine || branchLine !== `branch refs/heads/${branch}`) continue;\n\n const worktreePath = worktreeLine.replace(\"worktree \", \"\");\n if (!worktreePath.includes(`/${WORKTREE_DIR}/`)) continue;\n\n try {\n execSync(\"git checkout --detach\", { cwd: worktreePath, stdio: \"ignore\" });\n } catch {\n /* best effort */\n }\n }\n } catch {\n /* best effort */\n }\n}\n\n/**\n * Force-remove is intentional: this runs after task completion or explicit stop.\n * Any uncommitted changes at this point are scratch artifacts, not valuable work.\n */\nexport function removeWorktree(projectDir: string, taskId: string): void {\n const worktreePath = join(projectDir, WORKTREE_DIR, taskId);\n if (!existsSync(worktreePath)) return;\n try {\n execSync(`git worktree remove \"${worktreePath}\" --force`, {\n cwd: projectDir,\n stdio: \"ignore\",\n });\n } catch {\n /* best effort */\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,SAAS,gBAAgB;AACzB,SAAS,kBAAkB;AAC3B,SAAS,YAAY;AAGrB,IAAM,eAAe;AAEd,SAAS,eAAe,YAAoB,QAAgB,QAAyB;AAC1F,MAAI,WAAW,SAAS,IAAI,YAAY,GAAG,GAAG;AAC5C,WAAO;AAAA,EACT;AAEA,QAAM,eAAe,KAAK,YAAY,cAAc,MAAM;AAE1D,MAAI,WAAW,YAAY,GAAG;AAC5B,QAAI,QAAQ;AACV,UAAI,sBAAsB,YAAY,GAAG;AACvC,eAAO;AAAA,MACT;AACA,UAAI;AACF,iBAAS,gCAAgC,MAAM,IAAI;AAAA,UACjD,KAAK;AAAA,UACL,OAAO;AAAA,QACT,CAAC;AAAA,MACH,QAAQ;AAAA,MAER;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAEA,QAAM,MAAM,SAAS,UAAU,MAAM,KAAK;AAC1C,WAAS,8BAA8B,YAAY,KAAK,GAAG,IAAI;AAAA,IAC7D,KAAK;AAAA,IACL,OAAO;AAAA,EACT,CAAC;AAED,SAAO;AACT;AAOO,SAAS,qBAAqB,YAAoB,QAAsB;AAC7E,MAAI;AACF,UAAM,SAAS,SAAS,iCAAiC;AAAA,MACvD,KAAK;AAAA,MACL,UAAU;AAAA,IACZ,CAAC;AACD,UAAM,UAAU,OAAO,MAAM,MAAM;AACnC,eAAW,SAAS,SAAS;AAC3B,YAAM,QAAQ,MAAM,KAAK,EAAE,MAAM,IAAI;AACrC,YAAM,eAAe,MAAM,KAAK,CAAC,MAAM,EAAE,WAAW,WAAW,CAAC;AAChE,YAAM,aAAa,MAAM,KAAK,CAAC,MAAM,EAAE,WAAW,SAAS,CAAC;AAC5D,UAAI,CAAC,gBAAgB,eAAe,qBAAqB,MAAM,GAAI;AAEnE,YAAM,eAAe,aAAa,QAAQ,aAAa,EAAE;AACzD,UAAI,CAAC,aAAa,SAAS,IAAI,YAAY,GAAG,EAAG;AAEjD,UAAI;AACF,iBAAS,yBAAyB,EAAE,KAAK,cAAc,OAAO,SAAS,CAAC;AAAA,MAC1E,QAAQ;AAAA,MAER;AAAA,IACF;AAAA,EACF,QAAQ;AAAA,EAER;AACF;AAMO,SAAS,eAAe,YAAoB,QAAsB;AACvE,QAAM,eAAe,KAAK,YAAY,cAAc,MAAM;AAC1D,MAAI,CAAC,WAAW,YAAY,EAAG;AAC/B,MAAI;AACF,aAAS,wBAAwB,YAAY,aAAa;AAAA,MACxD,KAAK;AAAA,MACL,OAAO;AAAA,IACT,CAAC;AAAA,EACH,QAAQ;AAAA,EAER;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/runner/worktree.ts"],"sourcesContent":["import { execFile } from \"node:child_process\";\nimport { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { promisify } from \"node:util\";\nimport { hasUncommittedChanges } from \"./git-utils.js\";\n\n// Async git (execFile, no shell) — synchronous child processes freeze the\n// agent's event loop and starve the socket heartbeat; see git-utils.ts.\nconst execFileAsync = promisify(execFile);\nconst GIT_TIMEOUT_MS = 60_000;\n\nasync function git(cwd: string, args: string[]): Promise<string> {\n const { stdout } = await execFileAsync(\"git\", args, { cwd, timeout: GIT_TIMEOUT_MS });\n return stdout.toString();\n}\n\nconst WORKTREE_DIR = \".worktrees\";\n\nexport async function ensureWorktree(\n projectDir: string,\n taskId: string,\n branch?: string,\n): Promise<string> {\n if (projectDir.includes(`/${WORKTREE_DIR}/`)) {\n return projectDir;\n }\n\n const worktreePath = join(projectDir, WORKTREE_DIR, taskId);\n\n if (existsSync(worktreePath)) {\n if (branch) {\n if (await hasUncommittedChanges(worktreePath)) {\n return worktreePath;\n }\n try {\n await git(worktreePath, [\"checkout\", \"--detach\", `origin/${branch}`]);\n } catch {\n /* branch doesn't exist on remote yet */\n }\n }\n return worktreePath;\n }\n\n const ref = branch ? `origin/${branch}` : \"HEAD\";\n await git(projectDir, [\"worktree\", \"add\", \"--detach\", worktreePath, ref]);\n\n return worktreePath;\n}\n\n/**\n * Detach any task worktree that has `branch` checked out, releasing the branch lock\n * so the main project directory can check it out. Only touches worktrees inside\n * `.worktrees/` — never the main project directory.\n */\nexport async function detachWorktreeBranch(projectDir: string, branch: string): Promise<void> {\n try {\n const output = await git(projectDir, [\"worktree\", \"list\", \"--porcelain\"]);\n const entries = output.split(\"\\n\\n\");\n for (const entry of entries) {\n const lines = entry.trim().split(\"\\n\");\n const worktreeLine = lines.find((l) => l.startsWith(\"worktree \"));\n const branchLine = lines.find((l) => l.startsWith(\"branch \"));\n if (!worktreeLine || branchLine !== `branch refs/heads/${branch}`) continue;\n\n const worktreePath = worktreeLine.replace(\"worktree \", \"\");\n if (!worktreePath.includes(`/${WORKTREE_DIR}/`)) continue;\n\n try {\n await git(worktreePath, [\"checkout\", \"--detach\"]);\n } catch {\n /* best effort */\n }\n }\n } catch {\n /* best effort */\n }\n}\n\n/**\n * Force-remove is intentional: this runs after task completion or explicit stop.\n * Any uncommitted changes at this point are scratch artifacts, not valuable work.\n */\nexport async function removeWorktree(projectDir: string, taskId: string): Promise<void> {\n const worktreePath = join(projectDir, WORKTREE_DIR, taskId);\n if (!existsSync(worktreePath)) return;\n try {\n await git(projectDir, [\"worktree\", \"remove\", worktreePath, \"--force\"]);\n } catch {\n /* best effort */\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,SAAS,gBAAgB;AACzB,SAAS,kBAAkB;AAC3B,SAAS,YAAY;AACrB,SAAS,iBAAiB;AAK1B,IAAM,gBAAgB,UAAU,QAAQ;AACxC,IAAM,iBAAiB;AAEvB,eAAe,IAAI,KAAa,MAAiC;AAC/D,QAAM,EAAE,OAAO,IAAI,MAAM,cAAc,OAAO,MAAM,EAAE,KAAK,SAAS,eAAe,CAAC;AACpF,SAAO,OAAO,SAAS;AACzB;AAEA,IAAM,eAAe;AAErB,eAAsB,eACpB,YACA,QACA,QACiB;AACjB,MAAI,WAAW,SAAS,IAAI,YAAY,GAAG,GAAG;AAC5C,WAAO;AAAA,EACT;AAEA,QAAM,eAAe,KAAK,YAAY,cAAc,MAAM;AAE1D,MAAI,WAAW,YAAY,GAAG;AAC5B,QAAI,QAAQ;AACV,UAAI,MAAM,sBAAsB,YAAY,GAAG;AAC7C,eAAO;AAAA,MACT;AACA,UAAI;AACF,cAAM,IAAI,cAAc,CAAC,YAAY,YAAY,UAAU,MAAM,EAAE,CAAC;AAAA,MACtE,QAAQ;AAAA,MAER;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAEA,QAAM,MAAM,SAAS,UAAU,MAAM,KAAK;AAC1C,QAAM,IAAI,YAAY,CAAC,YAAY,OAAO,YAAY,cAAc,GAAG,CAAC;AAExE,SAAO;AACT;AAOA,eAAsB,qBAAqB,YAAoB,QAA+B;AAC5F,MAAI;AACF,UAAM,SAAS,MAAM,IAAI,YAAY,CAAC,YAAY,QAAQ,aAAa,CAAC;AACxE,UAAM,UAAU,OAAO,MAAM,MAAM;AACnC,eAAW,SAAS,SAAS;AAC3B,YAAM,QAAQ,MAAM,KAAK,EAAE,MAAM,IAAI;AACrC,YAAM,eAAe,MAAM,KAAK,CAAC,MAAM,EAAE,WAAW,WAAW,CAAC;AAChE,YAAM,aAAa,MAAM,KAAK,CAAC,MAAM,EAAE,WAAW,SAAS,CAAC;AAC5D,UAAI,CAAC,gBAAgB,eAAe,qBAAqB,MAAM,GAAI;AAEnE,YAAM,eAAe,aAAa,QAAQ,aAAa,EAAE;AACzD,UAAI,CAAC,aAAa,SAAS,IAAI,YAAY,GAAG,EAAG;AAEjD,UAAI;AACF,cAAM,IAAI,cAAc,CAAC,YAAY,UAAU,CAAC;AAAA,MAClD,QAAQ;AAAA,MAER;AAAA,IACF;AAAA,EACF,QAAQ;AAAA,EAER;AACF;AAMA,eAAsB,eAAe,YAAoB,QAA+B;AACtF,QAAM,eAAe,KAAK,YAAY,cAAc,MAAM;AAC1D,MAAI,CAAC,WAAW,YAAY,EAAG;AAC/B,MAAI;AACF,UAAM,IAAI,YAAY,CAAC,YAAY,UAAU,cAAc,SAAS,CAAC;AAAA,EACvE,QAAQ;AAAA,EAER;AACF;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rallycry/conveyor-agent",
3
- "version": "10.2.2",
3
+ "version": "10.2.4",
4
4
  "description": "Conveyor Agent Runner v10 - PTY harness for the task chat (SDK harness for audit/project-chat). Agent-as-User architecture with BaseService patterns. Works locally too.",
5
5
  "keywords": [
6
6
  "agent",