baxian 1.2.60 → 1.2.61

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 (117) hide show
  1. package/dist/agent/dispatch-reconciler.js +6 -6
  2. package/dist/agent/dispatch-reconciler.js.map +1 -1
  3. package/dist/agent/manager.d.ts +34 -4
  4. package/dist/agent/manager.d.ts.map +1 -1
  5. package/dist/agent/manager.js +628 -48
  6. package/dist/agent/manager.js.map +1 -1
  7. package/dist/agent/pane-streamer.js +1 -1
  8. package/dist/agent/pane-streamer.js.map +1 -1
  9. package/dist/agent/phase-signal-watcher.d.ts +43 -2
  10. package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
  11. package/dist/agent/phase-signal-watcher.js +301 -43
  12. package/dist/agent/phase-signal-watcher.js.map +1 -1
  13. package/dist/agent/phase-signal.d.ts +9 -0
  14. package/dist/agent/phase-signal.d.ts.map +1 -1
  15. package/dist/agent/phase-signal.js +63 -5
  16. package/dist/agent/phase-signal.js.map +1 -1
  17. package/dist/agent/preflight.d.ts +20 -1
  18. package/dist/agent/preflight.d.ts.map +1 -1
  19. package/dist/agent/preflight.js +217 -19
  20. package/dist/agent/preflight.js.map +1 -1
  21. package/dist/agent/prompt.d.ts +8 -0
  22. package/dist/agent/prompt.d.ts.map +1 -1
  23. package/dist/agent/prompt.js +45 -2
  24. package/dist/agent/prompt.js.map +1 -1
  25. package/dist/agent/repo-store.d.ts +2 -1
  26. package/dist/agent/repo-store.d.ts.map +1 -1
  27. package/dist/agent/repo-store.js +7 -3
  28. package/dist/agent/repo-store.js.map +1 -1
  29. package/dist/api/config.d.ts.map +1 -1
  30. package/dist/api/config.js +8 -0
  31. package/dist/api/config.js.map +1 -1
  32. package/dist/api/projects.d.ts.map +1 -1
  33. package/dist/api/projects.js +55 -4
  34. package/dist/api/projects.js.map +1 -1
  35. package/dist/api/tasks.d.ts.map +1 -1
  36. package/dist/api/tasks.js +26 -3
  37. package/dist/api/tasks.js.map +1 -1
  38. package/dist/cli.d.ts.map +1 -1
  39. package/dist/cli.js +7 -0
  40. package/dist/cli.js.map +1 -1
  41. package/dist/config/validator.js +8 -1
  42. package/dist/config/validator.js.map +1 -1
  43. package/dist/event/log.d.ts.map +1 -1
  44. package/dist/event/log.js +17 -2
  45. package/dist/event/log.js.map +1 -1
  46. package/dist/github/pr-conversation-cache.d.ts +11 -4
  47. package/dist/github/pr-conversation-cache.d.ts.map +1 -1
  48. package/dist/github/pr-conversation-cache.js +101 -7
  49. package/dist/github/pr-conversation-cache.js.map +1 -1
  50. package/dist/github/pr-conversation.d.ts +2 -2
  51. package/dist/github/pr-conversation.d.ts.map +1 -1
  52. package/dist/github/pr-conversation.js.map +1 -1
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +2 -1
  55. package/dist/index.js.map +1 -1
  56. package/dist/platform/driver-spec.d.ts.map +1 -1
  57. package/dist/platform/driver-spec.js +33 -0
  58. package/dist/platform/driver-spec.js.map +1 -1
  59. package/dist/platform/git-driver.d.ts +7 -1
  60. package/dist/platform/git-driver.d.ts.map +1 -1
  61. package/dist/platform/git-driver.js +33 -2
  62. package/dist/platform/git-driver.js.map +1 -1
  63. package/dist/platform/platform-poller.d.ts +8 -0
  64. package/dist/platform/platform-poller.d.ts.map +1 -1
  65. package/dist/platform/platform-poller.js +80 -1
  66. package/dist/platform/platform-poller.js.map +1 -1
  67. package/dist/platform/plugin-registry.d.ts.map +1 -1
  68. package/dist/platform/plugin-registry.js +14 -2
  69. package/dist/platform/plugin-registry.js.map +1 -1
  70. package/dist/platform/plugins/github/driver.json +3 -0
  71. package/dist/platform/plugins/github/skills/baxian-cli-gh/SKILL.md +16 -7
  72. package/dist/platform/preflight-exec.d.ts +21 -0
  73. package/dist/platform/preflight-exec.d.ts.map +1 -0
  74. package/dist/platform/preflight-exec.js +62 -0
  75. package/dist/platform/preflight-exec.js.map +1 -0
  76. package/dist/platform/review-timeline.d.ts +16 -0
  77. package/dist/platform/review-timeline.d.ts.map +1 -0
  78. package/dist/platform/review-timeline.js +195 -0
  79. package/dist/platform/review-timeline.js.map +1 -0
  80. package/dist/platform/startup.d.ts +5 -1
  81. package/dist/platform/startup.d.ts.map +1 -1
  82. package/dist/platform/startup.js +25 -0
  83. package/dist/platform/startup.js.map +1 -1
  84. package/dist/platform/types.d.ts +2 -0
  85. package/dist/platform/types.d.ts.map +1 -1
  86. package/dist/platform/types.js +1 -1
  87. package/dist/platform/types.js.map +1 -1
  88. package/dist/shared/git-url.d.ts +1 -0
  89. package/dist/shared/git-url.d.ts.map +1 -1
  90. package/dist/shared/git-url.js +3 -0
  91. package/dist/shared/git-url.js.map +1 -1
  92. package/dist/shared/types.d.ts +19 -6
  93. package/dist/shared/types.d.ts.map +1 -1
  94. package/dist/shared/types.js.map +1 -1
  95. package/dist/skill/registry.d.ts +9 -2
  96. package/dist/skill/registry.d.ts.map +1 -1
  97. package/dist/skill/registry.js +88 -54
  98. package/dist/skill/registry.js.map +1 -1
  99. package/dist/skills/baxian-pr-feedback/SKILL.md +2 -0
  100. package/dist/skills/baxian-pr-recheck/SKILL.md +2 -0
  101. package/dist/skills/baxian-pr-review/SKILL.md +2 -0
  102. package/dist/skills/baxian-research/SKILL.md +1 -1
  103. package/dist/skills/baxian-signals/SKILL.md +19 -4
  104. package/dist/skills/baxian-task-check/SKILL.md +4 -2
  105. package/dist/state/agent-store.d.ts.map +1 -1
  106. package/dist/state/agent-store.js +27 -2
  107. package/dist/state/agent-store.js.map +1 -1
  108. package/dist/state/task-store.js +3 -3
  109. package/dist/state/task-store.js.map +1 -1
  110. package/dist/terminal/attach.d.ts.map +1 -1
  111. package/dist/terminal/attach.js +7 -6
  112. package/dist/terminal/attach.js.map +1 -1
  113. package/dist/web/assets/{index-CwYBVTyn.css → index-BPv6vv3U.css} +1 -1
  114. package/dist/web/assets/index-CUJtd0bC.js +16 -0
  115. package/dist/web/index.html +2 -2
  116. package/package.json +1 -1
  117. package/dist/web/assets/index-DtnDvjOU.js +0 -16
@@ -6,7 +6,7 @@ import { createSignalToken } from './phase-signal.js';
6
6
  import { tmuxInstallHint } from './preflight.js';
7
7
  import { BRANCH_PREFIX, isValidBranchName, PHASE_EXPECTED_STATUS, PHASE_REQUIRES_AGENT_BOUND_TO_TASK, TASK_TERMINAL_STATUSES as TERMINAL_STATUSES, TASK_ACTIVE_STATUS_SET as ACTIVE_TASK_STATUSES, TASK_OWNER_ROLES, isSpecStagePhase, isGitHubRepo, parseGitRemote, repoIdentityKey, repoSlug, } from '../shared/index.js';
8
8
  import { buildProjectDriver, makeDriverExec } from '../platform/driver-host.js';
9
- import { DriverOpError } from '../platform/git-driver.js';
9
+ import { DriverOpError, buildDriverRunContext, GitDriver } from '../platform/git-driver.js';
10
10
  import { checkOpenPrBinding } from '../platform/pr-binding.js';
11
11
  import { collectPendingFeedback, scanCommentSourcesOnce } from '../platform/feedback.js';
12
12
  import { recheckPassProvenance } from '../platform/verdict-engine.js';
@@ -20,12 +20,12 @@ import { imageFilename, agentHostPath, writeImageToHost } from './image-input.js
20
20
  import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu, runtimeBusyCheck, hasRuntimeReadyView, hasReplProcTitle, hasOscTitleWorking, hasOscTitleIdle, screenAllowsTitleIdle, PaneGoneError, } from './tmux.js';
21
21
  import { BranchManager, DirtyWorkdirError, ReviewHeadMismatchError, isAutoDeletableTaskBranch } from './branch.js';
22
22
  import { RepoStore, createRepoStoreCache, moveFileIntoPlace, stageFileGuarded, canonicalSelfGuard } from './repo-store.js';
23
- import { PhaseSignalWatcher } from './phase-signal-watcher.js';
23
+ import { PhaseSignalWatcher, } from './phase-signal-watcher.js';
24
24
  import { ReviewTransport, resolveServerPayloads } from './review-transport.js';
25
25
  import { buildPromptInline, buildGreetingPrompt, PromptSizeError, RequiredSkillsMissingError, MAX_PROMPT_BYTES_ROUTE_LIMIT, } from './prompt.js';
26
26
  import { ApiError } from '../errors.js';
27
27
  import { prepareConfig } from '../config/loader.js';
28
- import { resolveProjectTool } from '../config/validator.js';
28
+ import { projectReviewMode, resolveProjectTool } from '../config/validator.js';
29
29
  import { SHA_HEX_SOURCE } from '../platform/types.js';
30
30
  export class EnsureSessionError extends Error {
31
31
  partial;
@@ -146,6 +146,7 @@ export function skillSubdirFor(runtime) {
146
146
  }
147
147
  }
148
148
  const DEFAULT_DISPATCH_ACK_TIMEOUT_MS = 30_000;
149
+ const DEFAULT_NEED_INPUT_RETRY_INTERVAL_MS = 5_000;
149
150
  const DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS = 3_000;
150
151
  // DELETE runtime cleanup runs while holding the deletion tombstone + rotated lock; an unbounded remote
151
152
  // probe/kill would block rollback/finally forever, so every tmux round-trip in it carries this deadline.
@@ -233,6 +234,7 @@ export class AgentManager {
233
234
  paneStreamerManager;
234
235
  postApproveStore;
235
236
  pluginRegistry;
237
+ platformDefaultBranchOf;
236
238
  platformDriverExec;
237
239
  phaseSignalWatcher;
238
240
  errorRecordStore;
@@ -240,6 +242,7 @@ export class AgentManager {
240
242
  reviewTransportInstance;
241
243
  serverReviewDriver;
242
244
  dispatchAckTimeoutMs;
245
+ needInputRetryIntervalMs;
243
246
  dispatchSettleTimeoutMs;
244
247
  dispatchAckResendIntervalMs = 3_000;
245
248
  taskMutationQueue = Promise.resolve();
@@ -283,6 +286,7 @@ export class AgentManager {
283
286
  this.paneStreamerManager = deps.paneStreamerManager;
284
287
  this.postApproveStore = deps.postApproveStore ?? new PostApproveStore();
285
288
  this.pluginRegistry = deps.pluginRegistry;
289
+ this.platformDefaultBranchOf = deps.platformDefaultBranchOf;
286
290
  this.errorRecordStore = deps.errorRecordStore;
287
291
  this.phaseSignalWatcher = deps.phaseSignalWatcher
288
292
  ?? (deps.paneStreamerManager
@@ -290,8 +294,10 @@ export class AgentManager {
290
294
  paneStreamerManager: deps.paneStreamerManager,
291
295
  eventBus: deps.eventBus,
292
296
  resolveAgent: (id) => this.getAgentConfig(id),
297
+ commitNeedInputWatermark: (intent) => this.commitNeedInputWatermark(intent),
293
298
  })
294
299
  : undefined);
300
+ this.needInputRetryIntervalMs = deps.needInputRetryIntervalMs ?? DEFAULT_NEED_INPUT_RETRY_INTERVAL_MS;
295
301
  this.reviewStore = deps.reviewStore;
296
302
  this.dispatchAckTimeoutMs = deps.dispatchAckTimeoutMs ?? DEFAULT_DISPATCH_ACK_TIMEOUT_MS;
297
303
  this.dispatchSettleTimeoutMs = deps.dispatchSettleTimeoutMs ?? DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS;
@@ -592,9 +598,19 @@ export class AgentManager {
592
598
  }
593
599
  return this.adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, workdir, snapshot);
594
600
  }
601
+ agentSkillScope(agentId) {
602
+ const projectId = this.getAgentConfig(agentId)?.projectId;
603
+ if (projectId === undefined || this.effectiveReviewMode(projectId) !== 'git') {
604
+ return { pluginTools: [] };
605
+ }
606
+ const project = this.getProjectConfig(projectId);
607
+ const tool = project === undefined ? undefined : resolveProjectTool(project);
608
+ return { pluginTools: tool === undefined ? [] : [tool] };
609
+ }
595
610
  async provisionRepoSkills(runner, agent, workdir) {
596
611
  if (this.skillRegistry.names().length === 0)
597
612
  return;
613
+ const scope = this.agentSkillScope(agent.id);
598
614
  const subdir = skillSubdirFor(agent.runtime);
599
615
  const destRoot = `${workdir}/${subdir}`;
600
616
  await this.excludeInjectedSkills(runner, workdir, subdir);
@@ -604,8 +620,11 @@ export class AgentManager {
604
620
  // would execute a `!`cmd`` / inline a @path embedded in an untrusted task title/description
605
621
  // before the skill runs. Message-body payloads are not expanded, so this path is injection-safe.
606
622
  await this.runUnderSkillDirLock(this.skillDirLockKey(agent, workdir), async () => {
607
- await this.ensureSkillDirSafe(runner, workdir, subdir);
608
- await this.skillRegistry.materialize((path, content) => this.atomicWriteFile(runner, workdir, path, content), destRoot);
623
+ await this.ensureSkillDirSafe(runner, workdir, subdir, [
624
+ ...this.skillRegistry.names(),
625
+ ...this.skillRegistry.pluginSkillNames(scope),
626
+ ]);
627
+ await this.skillRegistry.materialize((path, content) => this.atomicWriteFile(runner, workdir, path, content), destRoot, scope);
609
628
  });
610
629
  }
611
630
  // A rollback only kills if no adoption bumped the generation since its create.
@@ -706,9 +725,9 @@ export class AgentManager {
706
725
  throw new Error(`atomic skill write failed (${finalPath}): ${err instanceof Error ? err.message : String(err)}`);
707
726
  }
708
727
  }
709
- async ensureSkillDirSafe(runner, workdir, subdir) {
728
+ async ensureSkillDirSafe(runner, workdir, subdir, keepNames) {
710
729
  const top = subdir.split('/')[0];
711
- const keep = this.skillRegistry.names().map((n) => `! -name ${shellQuote(n)}`).join(' ');
730
+ const keep = keepNames.map((n) => `! -name ${shellQuote(n)}`).join(' ');
712
731
  const inner =
713
732
  // canonicalSelfGuard (cd+pwd -P) rejects a rebound MIDDLE ancestor, not just a symlinked leaf, so the find/rm can't escape.
714
733
  `${canonicalSelfGuard(workdir)} && ` +
@@ -729,14 +748,14 @@ export class AgentManager {
729
748
  if (this.skillRegistry.names().length === 0)
730
749
  return;
731
750
  await this.setSessionOptions(tmux, agentId, ref, [
732
- ['@baxian-skills-version', this.skillRegistry.contentHash()],
751
+ ['@baxian-skills-version', this.skillRegistry.contentHash(this.agentSkillScope(agentId))],
733
752
  ]);
734
753
  }
735
754
  async replSkillsStale(tmux, agentId, ref) {
736
755
  if (this.skillRegistry.names().length === 0)
737
756
  return false;
738
757
  const tagged = await tmux.getSessionOptionByRef(ref, agentId, '@baxian-skills-version');
739
- return tagged !== this.skillRegistry.contentHash();
758
+ return tagged !== this.skillRegistry.contentHash(this.agentSkillScope(agentId));
740
759
  }
741
760
  async excludeInjectedSkills(runner, workdir, subdir) {
742
761
  const inner =
@@ -1745,11 +1764,15 @@ export class AgentManager {
1745
1764
  ...(latest.workdir !== undefined ? { workdir: latest.workdir } : {}),
1746
1765
  ...(latest.startedAt !== undefined ? { startedAt: latest.startedAt } : {}),
1747
1766
  ...(latest.creationToken !== undefined ? { creationToken: latest.creationToken } : {}),
1767
+ // Release voids the open question: strip the watermark, bump the epoch so
1768
+ // any in-flight/queued commit of the old generation fences server-side.
1769
+ needInput: { epoch: (latest.needInput?.epoch ?? 0) + 1 },
1748
1770
  };
1749
1771
  return cleared;
1750
1772
  }
1751
1773
  return {
1752
1774
  ...latest,
1775
+ needInput: { epoch: (latest.needInput?.epoch ?? 0) + 1 },
1753
1776
  updatedAt: now,
1754
1777
  };
1755
1778
  });
@@ -1757,6 +1780,7 @@ export class AgentManager {
1757
1780
  console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} holds a newer generation than the captured one; keeping the hold`);
1758
1781
  return false;
1759
1782
  }
1783
+ this.clearNeedInputRetryFor(agentId, expectedTaskId);
1760
1784
  return true;
1761
1785
  }
1762
1786
  let sessionConfirmedAbsent = false;
@@ -1951,6 +1975,7 @@ export class AgentManager {
1951
1975
  ...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
1952
1976
  ...(!sessionConfirmedAbsent && existing.paneId !== undefined ? { paneId: existing.paneId } : {}),
1953
1977
  ...(existing.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
1978
+ needInput: { epoch: (existing.needInput?.epoch ?? 0) + 1 },
1954
1979
  updatedAt: now,
1955
1980
  };
1956
1981
  });
@@ -1958,6 +1983,7 @@ export class AgentManager {
1958
1983
  console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} hold generation changed during release; keeping the hold`);
1959
1984
  return false;
1960
1985
  }
1986
+ this.clearNeedInputRetryFor(agentId, expectedTaskId);
1961
1987
  return this.lockManager.releaseIfOwner(agentId, expectedTaskId, lockToken);
1962
1988
  });
1963
1989
  }
@@ -2143,48 +2169,469 @@ export class AgentManager {
2143
2169
  }
2144
2170
  }
2145
2171
  }
2146
- async setAgentNeedInput(agentId, pending, opts = {}) {
2147
- const now = new Date().toISOString();
2172
+ // ---- need-input watermark: epoch-fenced monotonic persistence + retry convergence ----
2173
+ needInputRetry = new Map();
2174
+ needInputRetryInFlight = new Set();
2175
+ // Watermark writes currently in the store chain. A restore bump snapshots these along
2176
+ // with the queue: an answer whose write has not settled yet would otherwise be invisible
2177
+ // to the migration and its error-enqueue would land after the arm already cleared the
2178
+ // queue — deleting the only proof the user answered.
2179
+ needInputWritesInFlight = new Map();
2180
+ needInputWriteSeq = 0;
2181
+ // Serializes the probe→bump→queue-clear section of concurrent arms for one
2182
+ // (agentId, taskId): without it a stale replay probed before a successor's bump can
2183
+ // itself bump afterwards, ghost-fencing the installed successor. All locked steps are
2184
+ // bounded (memory probe + one store update); subscribe stays outside the lock.
2185
+ needInputArmLocks = new Map();
2186
+ needInputRetryTimer = null;
2187
+ needInputRetryKey(agentId, taskId, epoch) {
2188
+ return `${agentId} ${taskId} ${epoch}`;
2189
+ }
2190
+ parseNeedInputRetryKey(key) {
2191
+ const [agentId, taskId, epochRaw] = key.split(' ');
2192
+ if (!agentId || !taskId || epochRaw === undefined)
2193
+ return null;
2194
+ const epoch = Number.parseInt(epochRaw, 10);
2195
+ if (!Number.isFinite(epoch))
2196
+ return null;
2197
+ return { agentId, taskId, epoch };
2198
+ }
2199
+ // Standard commit entry: one agentStore.update, NEVER called from inside another
2200
+ // updater (the store's per-id chain is non-reentrant).
2201
+ // The ledger entry lives until the result is fully handed over — an error is IN the
2202
+ // retry queue before deregistration, in the same synchronous continuation. Splitting
2203
+ // those two across promise jobs would open a window where neither the ledger nor the
2204
+ // queue holds the intent and a concurrent restore snapshot loses the answer.
2205
+ async commitNeedInputWatermark(intent) {
2206
+ const writeId = ++this.needInputWriteSeq;
2207
+ this.needInputWritesInFlight.set(writeId, intent);
2208
+ const key = this.needInputRetryKey(intent.agentId, intent.taskId, intent.epoch);
2148
2209
  try {
2149
- await this.agentStore.update(agentId, (existing) => {
2150
- if (!existing)
2210
+ const result = await this.writeNeedInputWatermark(intent);
2211
+ if (result === 'error') {
2212
+ this.enqueueNeedInputRetry(key, intent.askSeq, intent.answeredSeq);
2213
+ }
2214
+ else {
2215
+ this.settleNeedInputRetry(key, intent.askSeq, intent.answeredSeq, result);
2216
+ }
2217
+ return result;
2218
+ }
2219
+ catch (err) {
2220
+ this.enqueueNeedInputRetry(key, intent.askSeq, intent.answeredSeq);
2221
+ throw err;
2222
+ }
2223
+ finally {
2224
+ this.needInputWritesInFlight.delete(writeId);
2225
+ }
2226
+ }
2227
+ async writeNeedInputWatermark(intent) {
2228
+ let outcome = 'fenced';
2229
+ try {
2230
+ await this.agentStore.update(intent.agentId, (existing) => {
2231
+ if (!existing || existing.taskId !== intent.taskId) {
2232
+ outcome = 'fenced';
2151
2233
  return AGENT_STORE_NOOP;
2152
- // A stale watcher callback must not stamp a rebound agent: the badge belongs
2153
- // to the watch's task, so a taskId mismatch means the binding moved on.
2154
- if (opts.taskId !== undefined && existing.taskId !== opts.taskId)
2234
+ }
2235
+ const cur = existing.needInput;
2236
+ if ((cur?.epoch ?? 0) !== intent.epoch) {
2237
+ outcome = 'fenced';
2155
2238
  return AGENT_STORE_NOOP;
2156
- if (pending) {
2157
- if (existing.needInputAt !== undefined)
2158
- return AGENT_STORE_NOOP;
2159
- return { ...existing, needInputAt: now, updatedAt: now };
2160
2239
  }
2161
- if (existing.needInputAt === undefined)
2240
+ const askSeq = Math.max(cur?.askSeq ?? 0, intent.askSeq);
2241
+ const answeredSeq = Math.max(cur?.answeredSeq ?? 0, intent.answeredSeq);
2242
+ outcome = 'ok';
2243
+ if (cur && (cur.askSeq ?? 0) === askSeq && (cur.answeredSeq ?? 0) === answeredSeq) {
2162
2244
  return AGENT_STORE_NOOP;
2163
- const { needInputAt: _needInputAt, ...rest } = existing;
2164
- return { ...rest, updatedAt: now };
2245
+ }
2246
+ const now = new Date().toISOString();
2247
+ const at = askSeq > answeredSeq ? (cur?.at ?? now) : undefined;
2248
+ return {
2249
+ ...existing,
2250
+ needInput: {
2251
+ epoch: intent.epoch, askSeq, answeredSeq,
2252
+ ...(at !== undefined ? { at } : {}),
2253
+ ...(cur?.cutoverToken !== undefined ? { cutoverToken: cur.cutoverToken } : {}),
2254
+ },
2255
+ updatedAt: now,
2256
+ };
2165
2257
  });
2166
2258
  }
2167
2259
  catch (err) {
2168
- console.warn(`[AgentManager] setAgentNeedInput(${agentId}, ${pending}) failed:`, err);
2260
+ console.warn(`[AgentManager] needInput watermark write failed (agent=${intent.agentId} epoch=${intent.epoch}):`, err);
2261
+ return 'error';
2262
+ }
2263
+ return outcome;
2264
+ }
2265
+ enqueueNeedInputRetry(key, askSeq, answeredSeq) {
2266
+ const cur = this.needInputRetry.get(key);
2267
+ this.needInputRetry.set(key, {
2268
+ askSeq: Math.max(cur?.askSeq ?? 0, askSeq),
2269
+ answeredSeq: Math.max(cur?.answeredSeq ?? 0, answeredSeq),
2270
+ });
2271
+ this.ensureNeedInputRetryTimer();
2272
+ }
2273
+ // ok deletes only when the queued item is still covered by what was written
2274
+ // (a concurrent edge may have raised it mid-flight); fenced is terminal either way.
2275
+ settleNeedInputRetry(key, wroteAskSeq, wroteAnsweredSeq, result) {
2276
+ const cur = this.needInputRetry.get(key);
2277
+ if (!cur)
2278
+ return;
2279
+ if (result === 'fenced' || (cur.askSeq <= wroteAskSeq && cur.answeredSeq <= wroteAnsweredSeq)) {
2280
+ this.needInputRetry.delete(key);
2281
+ this.ensureNeedInputRetryTimer();
2282
+ }
2283
+ }
2284
+ clearNeedInputRetryFor(agentId, taskId) {
2285
+ for (const key of [...this.needInputRetry.keys()]) {
2286
+ const parsed = this.parseNeedInputRetryKey(key);
2287
+ if (parsed && parsed.agentId === agentId && parsed.taskId === taskId) {
2288
+ this.needInputRetry.delete(key);
2289
+ }
2290
+ }
2291
+ this.ensureNeedInputRetryTimer();
2292
+ }
2293
+ ensureNeedInputRetryTimer() {
2294
+ if (this.needInputRetry.size === 0) {
2295
+ if (this.needInputRetryTimer) {
2296
+ clearInterval(this.needInputRetryTimer);
2297
+ this.needInputRetryTimer = null;
2298
+ }
2299
+ return;
2300
+ }
2301
+ if (this.needInputRetryTimer)
2302
+ return;
2303
+ this.needInputRetryTimer = setInterval(() => {
2304
+ void this.needInputRetryPass();
2305
+ }, this.needInputRetryIntervalMs);
2306
+ this.needInputRetryTimer.unref?.();
2307
+ }
2308
+ async needInputRetryPass() {
2309
+ for (const [key, snapshot] of [...this.needInputRetry.entries()]) {
2310
+ if (this.needInputRetryInFlight.has(key))
2311
+ continue;
2312
+ const parsed = this.parseNeedInputRetryKey(key);
2313
+ if (!parsed) {
2314
+ this.needInputRetry.delete(key);
2315
+ continue;
2316
+ }
2317
+ this.needInputRetryInFlight.add(key);
2318
+ try {
2319
+ const result = await this.writeNeedInputWatermark({
2320
+ agentId: parsed.agentId,
2321
+ taskId: parsed.taskId,
2322
+ epoch: parsed.epoch,
2323
+ askSeq: snapshot.askSeq,
2324
+ answeredSeq: snapshot.answeredSeq,
2325
+ });
2326
+ if (result !== 'error') {
2327
+ this.settleNeedInputRetry(key, snapshot.askSeq, snapshot.answeredSeq, result);
2328
+ }
2329
+ }
2330
+ finally {
2331
+ this.needInputRetryInFlight.delete(key);
2332
+ }
2333
+ }
2334
+ this.ensureNeedInputRetryTimer();
2335
+ }
2336
+ collectSameEpochNeedInput(agentId, taskId, epoch) {
2337
+ let owed = null;
2338
+ const merge = (wm) => {
2339
+ owed = {
2340
+ askSeq: Math.max(owed?.askSeq ?? 0, wm.askSeq),
2341
+ answeredSeq: Math.max(owed?.answeredSeq ?? 0, wm.answeredSeq),
2342
+ };
2343
+ };
2344
+ for (const [key, wm] of this.needInputRetry) {
2345
+ const parsed = this.parseNeedInputRetryKey(key);
2346
+ if (!parsed || parsed.agentId !== agentId || parsed.taskId !== taskId)
2347
+ continue;
2348
+ if (parsed.epoch === epoch)
2349
+ merge(wm);
2350
+ }
2351
+ for (const intent of this.needInputWritesInFlight.values()) {
2352
+ if (intent.agentId !== agentId || intent.taskId !== taskId)
2353
+ continue;
2354
+ if (intent.epoch === epoch)
2355
+ merge(intent);
2356
+ }
2357
+ return owed;
2358
+ }
2359
+ // Only the generation this arm directly succeeds (bumps are +1 and serialized per key).
2360
+ // Anything older was already superseded — a fresh arm in between stripped its question
2361
+ // on purpose, and transplanting it would relight a prompt nobody asked.
2362
+ collectPredecessorNeedInputDelta(agentId, taskId, epoch) {
2363
+ let delta = null;
2364
+ const merge = (wm) => {
2365
+ delta = {
2366
+ askSeq: Math.max(delta?.askSeq ?? 0, wm.askSeq),
2367
+ answeredSeq: Math.max(delta?.answeredSeq ?? 0, wm.answeredSeq),
2368
+ };
2369
+ };
2370
+ for (const [key, wm] of this.needInputRetry) {
2371
+ const parsed = this.parseNeedInputRetryKey(key);
2372
+ if (!parsed || parsed.agentId !== agentId || parsed.taskId !== taskId)
2373
+ continue;
2374
+ if (parsed.epoch === epoch - 1)
2375
+ merge(wm);
2376
+ }
2377
+ for (const intent of this.needInputWritesInFlight.values()) {
2378
+ if (intent.agentId !== agentId || intent.taskId !== taskId)
2379
+ continue;
2380
+ if (intent.epoch === epoch - 1)
2381
+ merge(intent);
2169
2382
  }
2383
+ return delta;
2384
+ }
2385
+ async withNeedInputArmLock(agentId, taskId, fn) {
2386
+ const key = `${agentId} ${taskId}`;
2387
+ const prev = this.needInputArmLocks.get(key) ?? Promise.resolve();
2388
+ const run = prev.then(fn);
2389
+ const settled = run.then(() => undefined, () => undefined);
2390
+ this.needInputArmLocks.set(key, settled);
2391
+ void settled.finally(() => {
2392
+ if (this.needInputArmLocks.get(key) === settled)
2393
+ this.needInputArmLocks.delete(key);
2394
+ });
2395
+ return run;
2170
2396
  }
2171
- // Single entry for arming phase-signal watches: every path (dispatch, post-approve,
2172
- // recover, rollback) must carry the need-input callback, or that watch silently
2173
- // drops the side-channel and the badge never lights for its phase.
2174
- startPhaseSignalWatch(args) {
2397
+ // 'restore' must also merge queued retry seqs of the CURRENT persisted epoch: after
2398
+ // session-gone the queue can hold the only proof an answer happened (the entry is
2399
+ // gone, so needInputInherit has nothing to merge), and the arm clears the queue.
2400
+ async bumpNeedInputEpochForArm(agentId, taskId, mode, opts = {}) {
2401
+ // Contributions are collected INSIDE the updater: it runs when the store chain
2402
+ // reaches this bump, so an answer whose commit started while the bump was queued
2403
+ // (registered in the ledger, or already error-queued) is still visible. A snapshot
2404
+ // taken at call time would miss exactly that window.
2405
+ const collectContributions = () => {
2406
+ const queued = new Map();
2407
+ if (mode !== 'restore')
2408
+ return queued;
2409
+ const mergeContribution = (epoch, wm) => {
2410
+ const cur = queued.get(epoch);
2411
+ queued.set(epoch, {
2412
+ askSeq: Math.max(cur?.askSeq ?? 0, wm.askSeq),
2413
+ answeredSeq: Math.max(cur?.answeredSeq ?? 0, wm.answeredSeq),
2414
+ });
2415
+ };
2416
+ for (const [key, qwm] of this.needInputRetry) {
2417
+ const parsed = this.parseNeedInputRetryKey(key);
2418
+ if (!parsed || parsed.agentId !== agentId || parsed.taskId !== taskId)
2419
+ continue;
2420
+ mergeContribution(parsed.epoch, qwm);
2421
+ }
2422
+ for (const intent of this.needInputWritesInFlight.values()) {
2423
+ if (intent.agentId !== agentId || intent.taskId !== taskId)
2424
+ continue;
2425
+ mergeContribution(intent.epoch, intent);
2426
+ }
2427
+ return queued;
2428
+ };
2429
+ let wm = null;
2430
+ let snapshotBlind = false;
2431
+ // Scoped to THIS arm's token: a marker left by another token's replay says nothing
2432
+ // about the pane's ambiguity for us, so it is neither honoured nor carried forward.
2433
+ const keepCutover = (cur) => (opts.paneCutover
2434
+ ? opts.armToken
2435
+ : cur !== undefined && cur.cutoverToken === opts.armToken ? cur.cutoverToken : undefined);
2436
+ try {
2437
+ await this.agentStore.update(agentId, (existing) => {
2438
+ if (!existing || existing.taskId !== taskId)
2439
+ return AGENT_STORE_NOOP;
2440
+ const cur = existing.needInput;
2441
+ const contrib = collectContributions().get(cur?.epoch ?? 0);
2442
+ const epoch = (cur?.epoch ?? 0) + 1;
2443
+ const askSeq = mode === 'restore' ? Math.max(cur?.askSeq ?? 0, contrib?.askSeq ?? 0) : 0;
2444
+ const answeredSeq = mode === 'restore'
2445
+ ? Math.max(cur?.answeredSeq ?? 0, contrib?.answeredSeq ?? 0)
2446
+ : 0;
2447
+ wm = { epoch, askSeq, answeredSeq };
2448
+ const cutoverToken = keepCutover(cur);
2449
+ snapshotBlind = cutoverToken !== undefined;
2450
+ const at = askSeq > answeredSeq ? (cur?.at ?? new Date().toISOString()) : undefined;
2451
+ return {
2452
+ ...existing,
2453
+ needInput: {
2454
+ epoch, askSeq, answeredSeq,
2455
+ ...(at !== undefined ? { at } : {}),
2456
+ ...(cutoverToken !== undefined ? { cutoverToken } : {}),
2457
+ },
2458
+ updatedAt: new Date().toISOString(),
2459
+ };
2460
+ });
2461
+ }
2462
+ catch (err) {
2463
+ console.warn(`[AgentManager] needInput epoch bump failed (agent=${agentId} task=${taskId}):`, err);
2464
+ // Fall back to the persisted watermark for a restore: without it the entry would
2465
+ // start at 0/0 and swallow the answer to a question that is still open in the
2466
+ // store, leaving the badge lit forever. No bump means no generation advance —
2467
+ // the caller must not treat this as a fresh generation.
2468
+ if (mode === 'restore') {
2469
+ try {
2470
+ const cur = await this.agentStore.get(agentId);
2471
+ if (cur?.taskId === taskId && cur.needInput) {
2472
+ // Same-generation intents still owed to the store hold questions the
2473
+ // persisted watermark has not caught up with; merge them in.
2474
+ const owed = this.collectSameEpochNeedInput(agentId, taskId, cur.needInput.epoch);
2475
+ return {
2476
+ wm: {
2477
+ epoch: cur.needInput.epoch,
2478
+ askSeq: Math.max(cur.needInput.askSeq ?? 0, owed?.askSeq ?? 0),
2479
+ answeredSeq: Math.max(cur.needInput.answeredSeq ?? 0, owed?.answeredSeq ?? 0),
2480
+ },
2481
+ bumped: false,
2482
+ snapshotBlind: cur.needInput.cutoverToken === opts.armToken,
2483
+ };
2484
+ }
2485
+ }
2486
+ catch (readErr) {
2487
+ console.warn(`[AgentManager] needInput watermark read-back failed (agent=${agentId}):`, readErr);
2488
+ }
2489
+ }
2490
+ return { wm: null, bumped: false, snapshotBlind: true };
2491
+ }
2492
+ return { wm, bumped: wm !== null, snapshotBlind: wm === null ? true : snapshotBlind };
2493
+ }
2494
+ // Single entry for arming phase-signal watches — every path must flow through the
2495
+ // epoch bump, or stale-generation commits could survive the re-arm.
2496
+ async startPhaseSignalWatch(args) {
2175
2497
  if (!this.phaseSignalWatcher)
2176
- return Promise.resolve(true);
2177
- return this.phaseSignalWatcher.start({
2178
- ...args,
2179
- onNeedInput: (pending) => {
2180
- void this.setAgentNeedInput(args.agentId, pending, { taskId: args.taskId });
2181
- },
2498
+ return true;
2499
+ const { needInputMode, needInputPaneCutover: _paneCutover, ...rest } = args;
2500
+ const mode = needInputMode ?? 'restore';
2501
+ const watcher = this.phaseSignalWatcher;
2502
+ // Claim ownership before bumping: a rejected arm must not bump (it would fence the
2503
+ // owner), and the claim keeps this arm visible to later probes while it subscribes.
2504
+ const gateResult = await this.withNeedInputArmLock(args.agentId, args.taskId, async () => {
2505
+ const claim = watcher.claimArm?.({
2506
+ taskId: args.taskId,
2507
+ agentId: args.agentId,
2508
+ token: args.token,
2509
+ ...(args.onlyReplaceOwnToken !== undefined ? { onlyReplaceOwnToken: args.onlyReplaceOwnToken } : {}),
2510
+ ...(args.replaceScope !== undefined ? { replaceScope: args.replaceScope } : {}),
2511
+ });
2512
+ if (claim === null)
2513
+ return 'rejected';
2514
+ const bump = await this.bumpNeedInputEpochForArm(args.agentId, args.taskId, mode, {
2515
+ armToken: args.token,
2516
+ ...(args.needInputPaneCutover ? { paneCutover: true } : {}),
2517
+ });
2518
+ // Only a real generation advance may drop the queue: same-generation intents
2519
+ // (bump failed, watermark read back) are still owed to the store.
2520
+ if (bump.bumped)
2521
+ this.clearNeedInputRetryFor(args.agentId, args.taskId);
2522
+ return { ...bump, claim };
2182
2523
  });
2524
+ if (gateResult === 'rejected')
2525
+ return false;
2526
+ const { wm, bumped, snapshotBlind, claim } = gateResult;
2527
+ // Same synchronous section as the bump result: old-generation answers must not slip
2528
+ // between the bump and the handoff (start() migrates the predecessor synchronously).
2529
+ if (wm !== null && bumped && mode === 'restore') {
2530
+ const delta = this.collectPredecessorNeedInputDelta(args.agentId, args.taskId, wm.epoch);
2531
+ if (delta) {
2532
+ void this.commitNeedInputWatermark({
2533
+ agentId: args.agentId,
2534
+ taskId: args.taskId,
2535
+ epoch: wm.epoch,
2536
+ askSeq: Math.max(delta.askSeq, wm.askSeq),
2537
+ answeredSeq: Math.max(delta.answeredSeq, wm.answeredSeq),
2538
+ });
2539
+ }
2540
+ }
2541
+ // wm === null (no binding / store error): arm anyway with badge commits disabled —
2542
+ // the badge is display-only, the signal watch drives task progress.
2543
+ try {
2544
+ return await watcher.start({
2545
+ ...rest,
2546
+ ...(claim != null ? { armClaimId: claim } : {}),
2547
+ // Inherit is about restore semantics, not about having a generation: a degraded
2548
+ // restore still has to keep the predecessor's pending question in memory.
2549
+ needInputInherit: mode === 'restore',
2550
+ ...(snapshotBlind ? { needInputSnapshotBlind: true } : {}),
2551
+ ...(wm !== null ? { needInput: wm } : {}),
2552
+ });
2553
+ }
2554
+ finally {
2555
+ watcher.releaseArm?.(claim);
2556
+ }
2557
+ }
2558
+ // Web-fallback confirm: settles the newest known question (store ∪ retry queue) as
2559
+ // answered in ONE store update — usable when no watch entry exists (pre-recovery,
2560
+ // arm failure, post-stop). Queue contributions are snapshotted synchronously; the
2561
+ // updater derives everything else from `existing`, so no nested store calls.
2562
+ async confirmNeedInputAnswered(agentId) {
2563
+ const queueAsk = new Map();
2564
+ for (const [key, wm] of this.needInputRetry) {
2565
+ const parsed = this.parseNeedInputRetryKey(key);
2566
+ if (!parsed || parsed.agentId !== agentId)
2567
+ continue;
2568
+ const mapKey = `${parsed.taskId} ${parsed.epoch}`;
2569
+ queueAsk.set(mapKey, Math.max(queueAsk.get(mapKey) ?? 0, wm.askSeq));
2570
+ }
2571
+ for (const intent of this.needInputWritesInFlight.values()) {
2572
+ if (intent.agentId !== agentId)
2573
+ continue;
2574
+ const mapKey = `${intent.taskId} ${intent.epoch}`;
2575
+ queueAsk.set(mapKey, Math.max(queueAsk.get(mapKey) ?? 0, intent.askSeq));
2576
+ }
2577
+ let settled = null;
2578
+ try {
2579
+ await this.agentStore.update(agentId, (existing) => {
2580
+ if (!existing?.taskId)
2581
+ return AGENT_STORE_NOOP;
2582
+ const cur = existing.needInput;
2583
+ const epoch = cur?.epoch ?? 0;
2584
+ const qAsk = queueAsk.get(`${existing.taskId} ${epoch}`) ?? 0;
2585
+ const askSeq = Math.max(cur?.askSeq ?? 0, qAsk);
2586
+ const answeredSeq = Math.max(cur?.answeredSeq ?? 0, askSeq);
2587
+ settled = { taskId: existing.taskId, epoch, askSeq };
2588
+ if (cur && (cur.askSeq ?? 0) === askSeq && (cur.answeredSeq ?? 0) === answeredSeq && cur.at === undefined) {
2589
+ return AGENT_STORE_NOOP;
2590
+ }
2591
+ if (!cur && askSeq === 0)
2592
+ return AGENT_STORE_NOOP;
2593
+ return {
2594
+ ...existing,
2595
+ needInput: {
2596
+ epoch, askSeq, answeredSeq,
2597
+ ...(cur?.cutoverToken !== undefined ? { cutoverToken: cur.cutoverToken } : {}),
2598
+ },
2599
+ updatedAt: new Date().toISOString(),
2600
+ };
2601
+ });
2602
+ }
2603
+ catch (err) {
2604
+ console.warn(`[AgentManager] confirmNeedInputAnswered(${agentId}) failed:`, err);
2605
+ if (settled !== null) {
2606
+ const s = settled;
2607
+ this.enqueueNeedInputRetry(this.needInputRetryKey(agentId, s.taskId, s.epoch), s.askSeq, s.askSeq);
2608
+ }
2609
+ return;
2610
+ }
2611
+ if (settled !== null) {
2612
+ const s = settled;
2613
+ this.settleNeedInputRetry(this.needInputRetryKey(agentId, s.taskId, s.epoch), s.askSeq, s.askSeq, 'ok');
2614
+ }
2183
2615
  }
2184
- // The user typed into the agent's terminal — its pending question is being answered.
2616
+ // The user typed into the agent's terminal — its open question counts as answered.
2617
+ // The store fallback runs only when no watch entry exists (pre-recovery, arm failure,
2618
+ // post-stop): with a live entry it would race the entry's in-flight writes and could
2619
+ // confirm an ask that arrived after this input.
2185
2620
  async notifyHumanTerminalInput(agentId) {
2186
- this.phaseSignalWatcher?.rearmNeedInput(agentId);
2187
- await this.setAgentNeedInput(agentId, false);
2621
+ // Rearm first: it settles entry state synchronously, so a question printed during
2622
+ // this call cannot be confirmed by mistake. The binding read only decides whether a
2623
+ // watcher of the CURRENT task took the input — a leftover entry of a finished task
2624
+ // must not suppress the store fallback.
2625
+ const handled = await this.phaseSignalWatcher?.rearmNeedInput(agentId);
2626
+ let taskId;
2627
+ try {
2628
+ taskId = (await this.agentStore.get(agentId))?.taskId;
2629
+ }
2630
+ catch (err) {
2631
+ console.warn(`[AgentManager] notifyHumanTerminalInput(${agentId}) binding read failed:`, err);
2632
+ }
2633
+ if (taskId === undefined || !handled?.has(taskId))
2634
+ await this.confirmNeedInputAnswered(agentId);
2188
2635
  }
2189
2636
  async clearAwaitingHuman(agentId, opts = {}) {
2190
2637
  const now = new Date().toISOString();
@@ -2445,6 +2892,8 @@ export class AgentManager {
2445
2892
  next.lockToken = existing.lockToken;
2446
2893
  if (existing.startedAt !== undefined)
2447
2894
  next.startedAt = existing.startedAt;
2895
+ if (existing.needInput !== undefined)
2896
+ next.needInput = existing.needInput;
2448
2897
  }
2449
2898
  return next;
2450
2899
  });
@@ -3073,6 +3522,8 @@ export class AgentManager {
3073
3522
  const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, {
3074
3523
  skipSnapshot: true,
3075
3524
  onlyReplaceOwnToken: true,
3525
+ needInputMode: 'fresh',
3526
+ needInputPaneCutover: true,
3076
3527
  // git 双 watcher 下 fence 只应比较自己的 (taskId, agentId) 条目:task 域会拿
3077
3528
  // sibling(reconciliation)的不同 token 否决本应合法的重布防
3078
3529
  ...(task.reviewMode === 'git' ? { replaceScope: 'agent' } : {}),
@@ -3614,6 +4065,7 @@ export class AgentManager {
3614
4065
  agentId: task.agentId,
3615
4066
  expectedKinds: 'pr-merge-ready',
3616
4067
  token: value.token,
4068
+ needInputMode: 'fresh',
3617
4069
  });
3618
4070
  if (!armed) {
3619
4071
  // completion 没有 consumer 就不算安装成功:撤销本次写,durable pending(若有)随原状保留
@@ -3915,6 +4367,30 @@ export class AgentManager {
3915
4367
  }
3916
4368
  // 配置身份切换与任务创建共栅栏(TOCTOU):blocker 扫描与提交在 withTaskLock 内执行,
3917
4369
  // createTask 的「读配置快照+落任务」同经该锁,扫描时不可见的并发新任务不存在。
4370
+ // 启动期按「未引用」跳过 skill 池的插件,被热配置首次引用时同步重扫;仍不可物化即拒绝提交——
4371
+ // 可解析但物化为空的 tool 会让派发要求加载一个不存在的 skill。
4372
+ async ensurePluginSkillPools(next) {
4373
+ if (!this.pluginRegistry)
4374
+ return;
4375
+ for (const project of next.project) {
4376
+ if (projectReviewMode(next, project) !== 'git')
4377
+ continue;
4378
+ const tool = resolveProjectTool(project);
4379
+ if (tool === undefined)
4380
+ continue;
4381
+ const plugin = this.pluginRegistry.resolveTool(tool);
4382
+ // 静默放行会存下一个「无 driver、无平台 skill、重启即 fatal」的配置(启动期同判据)
4383
+ if (plugin === undefined) {
4384
+ throw new Error(`project '${project.id}': no git-driver plugin provides tool '${tool}' — install it under ~/.baxian/plugins/ first`);
4385
+ }
4386
+ if (this.skillRegistry.pluginSkillNames({ pluginTools: [tool] }).length > 0)
4387
+ continue;
4388
+ await this.skillRegistry.scanPluginSkills(tool, join(plugin.pluginPath, 'skills'));
4389
+ if (this.skillRegistry.pluginSkillNames({ pluginTools: [tool] }).length === 0) {
4390
+ throw new Error(`plugin '${plugin.manifest.name}' provides no materializable skills for tool '${tool}'`);
4391
+ }
4392
+ }
4393
+ }
3918
4394
  async guardGitConfigCommit(current, next, scan, commit) {
3919
4395
  return this.withTaskLock(async () => {
3920
4396
  const blockers = await scan(this, current, next);
@@ -4336,6 +4812,82 @@ export class AgentManager {
4336
4812
  return {};
4337
4813
  return { platformBinding: { mode: 'git', repoKey: repoIdentityKey(project.repo), tool } };
4338
4814
  }
4815
+ // agent 面一律 PATH 解析 tool;server 面 binary/env 不进入该轨。
4816
+ agentGitPreflightContext(projectId) {
4817
+ if (this.effectiveReviewMode(projectId) !== 'git')
4818
+ return undefined;
4819
+ const project = this.getProjectConfig(projectId);
4820
+ if (!project || !this.pluginRegistry)
4821
+ return undefined;
4822
+ const tool = resolveProjectTool(project);
4823
+ if (tool === undefined)
4824
+ return undefined;
4825
+ const plugin = this.pluginRegistry.resolveTool(tool);
4826
+ if (plugin === undefined)
4827
+ return undefined;
4828
+ const renderCtx = buildDriverRunContext(project.repo, tool);
4829
+ return {
4830
+ tool,
4831
+ minToolVersion: plugin.manifest.minToolVersion,
4832
+ steps: plugin.spec.preflight,
4833
+ agentCommands: plugin.spec.agentCommands,
4834
+ renderCtx,
4835
+ driverFor: (exec) => new GitDriver(plugin, renderCtx, exec),
4836
+ };
4837
+ }
4838
+ // cli 字段族每次派发实时渲染,不做创建时快照。渲染前按持久化 binding 校验身份——
4839
+ // 离线漂移的配置会把旧任务的 PR 号/分支/令牌带到新仓库,与平台操作同一守卫。
4840
+ platformCliContextOf(task) {
4841
+ if (task.reviewMode !== 'git')
4842
+ return undefined;
4843
+ this.assertPlatformBinding(task);
4844
+ const project = this.getProjectConfig(task.projectId);
4845
+ if (!project)
4846
+ return undefined;
4847
+ const tool = resolveProjectTool(project);
4848
+ if (tool === undefined)
4849
+ return undefined;
4850
+ const ctx = buildDriverRunContext(project.repo, tool);
4851
+ return {
4852
+ tool,
4853
+ host: ctx.host,
4854
+ repo: ctx.repoPath,
4855
+ repoEncoded: encodeURIComponent(ctx.repoPath),
4856
+ ...(project.gitCli?.notes !== undefined ? { notes: project.gitCli.notes } : {}),
4857
+ };
4858
+ }
4859
+ // base 快照一经持久化不可变;无快照保持缺省,由 agent 现场显式查询。
4860
+ async ensureGitBaseSnapshot(task, phase) {
4861
+ if (task.reviewMode !== 'git' || (phase !== 'develop' && phase !== 'code'))
4862
+ return task;
4863
+ if (task.baseBranch !== undefined)
4864
+ return task;
4865
+ // 读默认分支与落快照都以 binding 为前提:失配任务先写入新仓库的 base 会永久污染不可变快照
4866
+ this.assertPlatformBinding(task);
4867
+ const base = this.platformDefaultBranchOf?.(task.projectId);
4868
+ if (base === undefined)
4869
+ return task;
4870
+ return this.withTaskLock(async () => {
4871
+ const fresh = await this.taskStore.get(task.id);
4872
+ if (!fresh)
4873
+ return task;
4874
+ if (fresh.baseBranch !== undefined)
4875
+ return fresh;
4876
+ this.assertPlatformBinding(fresh);
4877
+ const next = { ...fresh, baseBranch: base, updatedAt: new Date().toISOString() };
4878
+ await this.taskStore.set(next);
4879
+ return next;
4880
+ });
4881
+ }
4882
+ async noteReviewConversationRevision(taskId) {
4883
+ await this.withTaskLock(async () => {
4884
+ const task = await this.taskStore.get(taskId);
4885
+ if (!task || TERMINAL_STATUSES.includes(task.status))
4886
+ return;
4887
+ const now = new Date().toISOString();
4888
+ await this.taskStore.set({ ...task, reviewConversationUpdatedAt: now, updatedAt: now });
4889
+ });
4890
+ }
4339
4891
  // 指纹为纯身份三元组(spec §4):binary/env/skill 文本 live 解析可演进,身份失配即拒绝
4340
4892
  assertPlatformBinding(task) {
4341
4893
  const binding = task.platformBinding;
@@ -4672,7 +5224,10 @@ export class AgentManager {
4672
5224
  if (this.repoStoreFactory) {
4673
5225
  return this.repoStoreFactory(runner, project.repo, agent.mode, host, this.repoCache, agent.id, agent.workdir);
4674
5226
  }
4675
- return new RepoStore(runner, project.repo, agent.mode, host, this.repoCache, agent.id, agent.workdir);
5227
+ const cloneViaGh = this.effectiveReviewMode(project.id) === 'git'
5228
+ ? resolveProjectTool(project) === 'gh'
5229
+ : undefined;
5230
+ return new RepoStore(runner, project.repo, agent.mode, host, this.repoCache, agent.id, agent.workdir, cloneViaGh);
4676
5231
  }
4677
5232
  async ensureWorkdir(agent, project, runner) {
4678
5233
  const repoStore = this.createRepoStore(agent, project, runner);
@@ -4915,6 +5470,8 @@ export class AgentManager {
4915
5470
  ...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
4916
5471
  ...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
4917
5472
  ...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
5473
+ // A new task never inherits the previous one's question: void the watermark.
5474
+ needInput: { epoch: (existing?.needInput?.epoch ?? 0) + 1 },
4918
5475
  };
4919
5476
  });
4920
5477
  }
@@ -5432,6 +5989,8 @@ export class AgentManager {
5432
5989
  ...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
5433
5990
  ...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
5434
5991
  ...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
5992
+ // A new task never inherits the previous one's question: void the watermark.
5993
+ needInput: { epoch: (existing?.needInput?.epoch ?? 0) + 1 },
5435
5994
  };
5436
5995
  });
5437
5996
  }
@@ -5757,9 +6316,9 @@ export class AgentManager {
5757
6316
  await this.agentStore.update(agentId, (stateNow) => {
5758
6317
  if (!stateNow || stateNow.taskId !== taskId)
5759
6318
  return AGENT_STORE_NOOP;
5760
- const { needInputAt: _needInputAt, ...rest } = stateNow;
6319
+ // Watermark stays: the arm-time epoch bump owns superseding the old question.
5761
6320
  return {
5762
- ...rest,
6321
+ ...stateNow,
5763
6322
  paneId,
5764
6323
  workdir,
5765
6324
  updatedAt: new Date().toISOString(),
@@ -5792,13 +6351,16 @@ export class AgentManager {
5792
6351
  ...(opts.serverPriorResponse ? { serverPriorResponse: opts.serverPriorResponse } : {}),
5793
6352
  });
5794
6353
  }
6354
+ const taskForPrompt = await this.ensureGitBaseSnapshot(task, phase);
6355
+ const platformCli = this.platformCliContextOf(taskForPrompt);
5795
6356
  prompt = buildPromptInline({
5796
- task,
6357
+ task: taskForPrompt,
5797
6358
  phase,
5798
6359
  agent,
5799
6360
  workdir: dispatchWorkdir,
5800
6361
  skillRegistry: this.skillRegistry,
5801
6362
  hasQaPartner,
6363
+ ...(platformCli ? { platformCli } : {}),
5802
6364
  ...(promptSignalToken ? { signalToken: promptSignalToken } : {}),
5803
6365
  ...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
5804
6366
  ...(imagePaths.length ? { imagePaths } : {}),
@@ -6368,13 +6930,16 @@ export class AgentManager {
6368
6930
  ...(opts.serverPriorResponse ? { serverPriorResponse: opts.serverPriorResponse } : {}),
6369
6931
  });
6370
6932
  }
6933
+ const taskForPrompt = await this.ensureGitBaseSnapshot(task, phase);
6934
+ const platformCli = this.platformCliContextOf(taskForPrompt);
6371
6935
  prompt = buildPromptInline({
6372
- task,
6936
+ task: taskForPrompt,
6373
6937
  phase,
6374
6938
  agent,
6375
6939
  workdir: verifiedWorkdir,
6376
6940
  skillRegistry: this.skillRegistry,
6377
6941
  hasQaPartner: task.qaAgentId !== undefined,
6942
+ ...(platformCli ? { platformCli } : {}),
6378
6943
  ...(signalToken ? { signalToken } : {}),
6379
6944
  ...(useIncrementalNudge
6380
6945
  ? { postApproveRedispatchCount: opts.postApproveRedispatchCount }
@@ -6460,10 +7025,11 @@ export class AgentManager {
6460
7025
  || latest.workdir !== verifiedWorkdir) {
6461
7026
  return AGENT_STORE_NOOP;
6462
7027
  }
6463
- // The continuation prompt supersedes any question asked under the previous one.
6464
- const { needInputAt: _needInputAt, ...rest } = latest;
7028
+ // The continuation prompt supersedes the previous question, but the arm-time
7029
+ // epoch bump owns that transition — stripping here would race an arm that
7030
+ // already re-established the watermark (post-approve arms before injecting).
6465
7031
  return {
6466
- ...rest,
7032
+ ...latest,
6467
7033
  paneId,
6468
7034
  workdir: verifiedWorkdir,
6469
7035
  lockToken,
@@ -6766,6 +7332,7 @@ export class AgentManager {
6766
7332
  ...(latest.taskId !== undefined ? { taskId: latest.taskId } : {}),
6767
7333
  ...(latest.lockToken !== undefined ? { lockToken: latest.lockToken } : {}),
6768
7334
  ...(latest.startedAt !== undefined ? { startedAt: latest.startedAt } : {}),
7335
+ ...(latest.needInput !== undefined ? { needInput: latest.needInput } : {}),
6769
7336
  };
6770
7337
  if (!preserveHeld)
6771
7338
  return withBinding;
@@ -7360,7 +7927,10 @@ export class AgentManager {
7360
7927
  }
7361
7928
  opts.onPassArmed?.(armedToken);
7362
7929
  const dispatchTaskAfterClaim = await this.taskStore.get(taskId);
7363
- const armed = await this.setupPhaseSignalWatcher(taskId, qaId, ['pr-approved', 'pr-changes-requested'], armedToken, dispatchTaskAfterClaim?.reviewMode === 'git' ? { replaceScope: 'agent' } : {});
7930
+ const armed = await this.setupPhaseSignalWatcher(taskId, qaId, ['pr-approved', 'pr-changes-requested'], armedToken, {
7931
+ needInputMode: 'fresh',
7932
+ ...(dispatchTaskAfterClaim?.reviewMode === 'git' ? { replaceScope: 'agent' } : {}),
7933
+ });
7364
7934
  if (armed && dispatchTaskAfterClaim?.reviewMode === 'git') {
7365
7935
  await this.rearmGitReconciliationWatcher(taskId);
7366
7936
  }
@@ -8322,6 +8892,8 @@ export class AgentManager {
8322
8892
  ...(opts.onlyReplaceOwnToken ? { onlyReplaceOwnToken: true } : {}),
8323
8893
  ...(opts.replaceScope ? { replaceScope: opts.replaceScope } : {}),
8324
8894
  ...(opts.onReadFile ? { onReadFile: opts.onReadFile } : {}),
8895
+ ...(opts.needInputMode ? { needInputMode: opts.needInputMode } : {}),
8896
+ ...(opts.needInputPaneCutover ? { needInputPaneCutover: true } : {}),
8325
8897
  });
8326
8898
  }
8327
8899
  catch (err) {
@@ -8332,6 +8904,10 @@ export class AgentManager {
8332
8904
  async armPostDispatchSignalOrHold(taskId, agentId, expectedKinds, token, skipSnapshot = false, onReadFile) {
8333
8905
  const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, {
8334
8906
  skipSnapshot,
8907
+ // Every post-dispatch arm follows a freshly injected prompt: the previous
8908
+ // question is superseded, its watermark must not carry over (fix/publish
8909
+ // continuations included).
8910
+ needInputMode: 'fresh',
8335
8911
  ...(onReadFile ? { onReadFile } : {}),
8336
8912
  });
8337
8913
  if (!armed)
@@ -8372,7 +8948,10 @@ export class AgentManager {
8372
8948
  // pair 的重铸已随「进入 review 的 transition」单写落盘(spec §7 与新轮可见性原子);
8373
8949
  // 这里只轮换 signal token 并布防
8374
8950
  await this.updateTask(taskId, { signalToken: newToken });
8375
- const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, newToken, isGit ? { replaceScope: 'agent' } : {});
8951
+ const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, newToken, {
8952
+ needInputMode: 'fresh',
8953
+ ...(isGit ? { replaceScope: 'agent' } : {}),
8954
+ });
8376
8955
  if (armed && isGit && kinds.includes('pr-approved'))
8377
8956
  await this.rearmGitReconciliationWatcher(taskId);
8378
8957
  return { token: newToken, armed };
@@ -8782,7 +9361,7 @@ export class AgentManager {
8782
9361
  signalToken,
8783
9362
  specDocuments,
8784
9363
  ...(currentSpecRound !== undefined ? { currentSpecRound } : {}),
8785
- armBeforeInject: () => this.setupPhaseSignalWatcher(task.id, devAgentId, expectedKind, signalToken),
9364
+ armBeforeInject: () => this.setupPhaseSignalWatcher(task.id, devAgentId, expectedKind, signalToken, { needInputMode: 'fresh' }),
8786
9365
  };
8787
9366
  return this.isResearchHandoff(task, devAgentId)
8788
9367
  ? this.startSession(task.id, devAgentId, 'code', {
@@ -9046,6 +9625,7 @@ export class AgentManager {
9046
9625
  dispatchedCheckoutMode = ctx.serverReviewCheckout;
9047
9626
  const allowReadFile = opts.phase === 'spec' || opts.continuation || ctx.serverReviewCheckout === 'base';
9048
9627
  return this.setupPhaseSignalWatcher(taskId, qaId, expectedKind, newToken, {
9628
+ needInputMode: 'fresh',
9049
9629
  ...(allowReadFile
9050
9630
  ? { onReadFile: (req) => { void this.handleReadFileRequest(taskId, qaId, req); } }
9051
9631
  : {}),