@yemi33/minions 0.1.1623 → 0.1.1625

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1625 (2026-04-29)
4
+
5
+ ### Fixes
6
+ - stream Copilot task completion summaries
7
+
3
8
  ## 0.1.1623 (2026-04-29)
4
9
 
5
10
  ### Features
@@ -11,8 +11,8 @@ tick()
11
11
  1. checkTimeouts() Kill stale/hung agents (>heartbeatTimeout)
12
12
  2. consolidateInbox() Merge learnings into notes.md (Haiku-powered)
13
13
  2.5 runCleanup() Periodic cleanup (every 10 ticks ≈ 10min)
14
- 2.6 pollPrStatus() Poll ADO + GitHub for build, review, merge status (every prPollStatusEvery ticks, default 12 ≈ 12min)
15
- 2.7 pollPrHumanComments() Poll PR threads for human @minions comments (every prPollCommentsEvery ticks, default 12 ≈ 12min)
14
+ 2.6 pollPrStatus() Poll ADO + GitHub for build, review, merge status (wall-clock cadence from prPollStatusEvery × tickInterval, default ≈ 12min)
15
+ 2.7 pollPrHumanComments() Poll PR threads for human @minions comments (wall-clock cadence from prPollCommentsEvery × tickInterval, default ≈ 12min)
16
16
  3. discoverWork() Scan ALL linked projects for new tasks
17
17
  4. updateSnapshot() Write identity/now.md
18
18
  5. dispatch Spawn agents for pending items (up to maxConcurrent)
@@ -124,7 +124,7 @@ Both write to `work-items.json` and are picked up by Source 3 on the same or nex
124
124
 
125
125
  ## PR Status Polling (`pollPrStatus`)
126
126
 
127
- **Runs:** Every `prPollStatusEvery` ticks (default 12, ≈ 12 minutes), independently of work discovery. ADO polling lives in `engine/ado.js`; GitHub polling lives in `engine/github.js` — both run in parallel each cycle (`Promise.allSettled`) and write to the same per-project `pull-requests.json` schema. Replaces the retired agent-based `pr-sync`.
127
+ **Runs:** On a wall-clock cadence derived from `prPollStatusEvery × engine.tickInterval` (default 12 × 60s, ≈ 12 minutes), independently of work discovery or file-change wakeups. ADO polling lives in `engine/ado.js`; GitHub polling lives in `engine/github.js` — both run in parallel each cycle (`Promise.allSettled`) and write to the same per-project `pull-requests.json` schema. Replaces the retired agent-based `pr-sync`.
128
128
 
129
129
  The engine directly polls the host REST API for **all** PR metadata: build/CI status, human review votes, and completion state. Two API calls per PR — no agent dispatch needed.
130
130
 
@@ -22,7 +22,7 @@ Minions persists all runtime state as flat JSON files guarded by file-lock-based
22
22
  | `engine/metrics.json` | 5 KB | Per-agent stats | R/W on PR approval/merge | Low | Low |
23
23
  | `engine/control.json` | 169 B | Single object | R/W on start/stop/heartbeat | Low | Low |
24
24
  | `projects/*/work-items.json` | 370 KB | 180 items | R/W every 1-2 ticks; dashboard reads on-demand | **High** — engine + lifecycle + dashboard | High |
25
- | `projects/*/pull-requests.json` | 241 KB | 128 PRs | R/W every `prPollStatusEvery` ticks (default 12, polling); lifecycle writes | Medium | Medium |
25
+ | `projects/*/pull-requests.json` | 241 KB | 128 PRs | R/W on the `prPollStatusEvery × tickInterval` wall-clock cadence (default ≈12min); lifecycle writes | Medium | Medium |
26
26
  | `engine/pipeline-runs.json` | 36 KB | Pipeline state | R/W on pipeline execution | Low | Low |
27
27
  | `engine/schedule-runs.json` | 115 B | Last-run times | R every 10 ticks; W on schedule execution | Low | Low |
28
28
 
@@ -56,7 +56,7 @@ Key properties:
56
56
  | Engine tick cycle | ~15 | ~3 | Heavy read, selective write |
57
57
  | Dashboard (per page load) | ~8 | 0 | Read-only display |
58
58
  | Dashboard (user action) | ~2 | ~2 | Read-modify-write |
59
- | PR polling (every `prPollStatusEvery` ticks, default 12) | ~4 | ~2 | Batch read-modify-write |
59
+ | PR polling (every `prPollStatusEvery × tickInterval`, default ≈12min) | ~4 | ~2 | Batch read-modify-write |
60
60
  | Consolidation (every 10 ticks) | ~3 | ~2 | Read inbox files, write notes.md |
61
61
 
62
62
  **Read:write ratio is approximately 8:1.** This strongly favors a system that can serve reads without locking (e.g., WAL mode).
@@ -71,7 +71,7 @@ When multiple problems coexist, earlier triggers get the first chance to enqueue
71
71
 
72
72
  ## 6. Re-review cycle
73
73
 
74
- - Poller (every `prPollStatusEvery` ticks, default ~12min): detects new commit (`head.sha` changed) → sets `lastPushedAt`
74
+ - Poller (wall-clock cadence from `prPollStatusEvery × tickInterval`, default ~12min): detects new commit (`head.sha` changed) → sets `lastPushedAt`
75
75
  - Platform review state drives next action:
76
76
  - Reviewer approves → `approved` → done
77
77
  - Reviewer re-requests changes → `changes-requested` → triggers another fix
package/engine/ado.js CHANGED
@@ -27,6 +27,24 @@ const getAdoPrUrl = (project, prNumber) => {
27
27
  return `https://dev.azure.com/${project.adoOrg}/${project.adoProject}/_git/${repoPath}/pullrequest/${prNumber}`;
28
28
  };
29
29
 
30
+ function isGitHubProject(project) {
31
+ return String(project?.repoHost || '').toLowerCase() === 'github';
32
+ }
33
+
34
+ function getAdoRepositoryId(project) {
35
+ const repositoryId = String(project?.repositoryId || '').trim();
36
+ if (repositoryId) return repositoryId;
37
+ return String(project?.repoName || '').trim();
38
+ }
39
+
40
+ function getAdoProjectLabel(project) {
41
+ return project?.name || project?.repoName || `${project?.adoOrg || 'unknown-org'}/${project?.adoProject || 'unknown-project'}`;
42
+ }
43
+
44
+ function logMissingAdoRepository(project, purpose) {
45
+ log('error', `${purpose} disabled for project ${getAdoProjectLabel(project)}: missing project.repositoryId and project.repoName; configure one so Azure DevOps repository API calls can target the repo`);
46
+ }
47
+
30
48
  // ── Build/Review Status Helpers ───────────────────────────────────────────────
31
49
 
32
50
  /** Classify an array of ADO build records into a single status string. */
@@ -238,7 +256,7 @@ async function fetchAdoBuildErrorLog(orgBase, project, failedStatus, token, pr,
238
256
  // ─── Shared PR Polling Loop ──────────────────────────────────────────────────
239
257
 
240
258
  /**
241
- * Iterate active PRs across all projects. Calls `callback(project, pr, prNum, orgBase)`
259
+ * Iterate active PRs across all projects. Calls `callback(project, pr, prNum, orgBase, adoRepositoryId)`
242
260
  * for each active PR. If callback returns truthy, the PR file is saved after the project loop.
243
261
  */
244
262
  async function forEachActivePr(config, token, callback) {
@@ -246,12 +264,19 @@ async function forEachActivePr(config, token, callback) {
246
264
  let totalUpdated = 0;
247
265
 
248
266
  for (const project of projects) {
249
- if (!project.adoOrg || !project.adoProject || !project.repositoryId) continue;
267
+ if (isGitHubProject(project)) continue;
268
+ if (!project.adoOrg || !project.adoProject) continue;
250
269
 
251
270
  const prs = getPrs(project);
252
271
  const activePrs = prs.filter(pr => shared.PR_POLLABLE_STATUSES.has(pr.status));
253
272
  if (activePrs.length === 0) continue;
254
273
 
274
+ const adoRepositoryId = getAdoRepositoryId(project);
275
+ if (!adoRepositoryId) {
276
+ logMissingAdoRepository(project, 'ADO PR polling');
277
+ continue;
278
+ }
279
+
255
280
  let projectUpdated = 0;
256
281
  const orgBase = getAdoOrgBase(project);
257
282
 
@@ -262,7 +287,7 @@ async function forEachActivePr(config, token, callback) {
262
287
  const results = await Promise.allSettled(batch.map(async (pr) => {
263
288
  const prNum = shared.getPrNumber(pr);
264
289
  if (!prNum) return false;
265
- return callback(project, pr, prNum, orgBase);
290
+ return callback(project, pr, prNum, orgBase, adoRepositoryId);
266
291
  }));
267
292
  for (const r of results) {
268
293
  if (r.status === 'fulfilled' && r.value) projectUpdated++;
@@ -275,7 +300,11 @@ async function forEachActivePr(config, token, callback) {
275
300
  // Merge back updated PRs — preserve disk values that may have been changed
276
301
  // by other writers between poll start and this write
277
302
  for (const updatedPr of activePrs) {
278
- const idx = currentPrs.findIndex(p => p.id === updatedPr.id);
303
+ const updatedPrNumber = shared.getPrNumber(updatedPr);
304
+ const idx = currentPrs.findIndex(p =>
305
+ p.id === updatedPr.id
306
+ || (updatedPrNumber != null && shared.getPrNumber(p) === updatedPrNumber)
307
+ );
279
308
  if (idx >= 0) {
280
309
  // Never downgrade reviewStatus from 'approved' — it's a permanent terminal state
281
310
  // The disk version may have been set to 'approved' by another writer after we read
@@ -317,9 +346,10 @@ async function pollPrStatus(config) {
317
346
  return;
318
347
  }
319
348
 
320
- const totalUpdated = await forEachActivePr(config, token, async (project, pr, prNum, orgBase) => {
349
+ const totalUpdated = await forEachActivePr(config, token, async (project, pr, prNum, orgBase, adoRepositoryId) => {
321
350
  try {
322
- const repoBase = `${orgBase}/${project.adoProject}/_apis/git/repositories/${project.repositoryId}/pullrequests/${prNum}`;
351
+ const encodedRepoId = encodeURIComponent(adoRepositoryId);
352
+ const repoBase = `${orgBase}/${project.adoProject}/_apis/git/repositories/${encodedRepoId}/pullrequests/${prNum}`;
323
353
  let updated = false;
324
354
 
325
355
  // Clear stale flag — we're attempting a fresh poll
@@ -453,7 +483,7 @@ async function pollPrStatus(config) {
453
483
  if (prNumber && mergeCommitId) {
454
484
  try {
455
485
  const mergeRef = encodeURIComponent(`refs/pull/${prNumber}/merge`);
456
- const buildsUrl = `${orgBase}/${project.adoProject}/_apis/build/builds?branchName=${mergeRef}&repositoryId=${project.repositoryId}&repositoryType=TfsGit&$top=25&api-version=7.1`;
486
+ const buildsUrl = `${orgBase}/${project.adoProject}/_apis/build/builds?branchName=${mergeRef}&repositoryId=${encodedRepoId}&repositoryType=TfsGit&$top=25&api-version=7.1`;
457
487
  const buildsData = await adoFetch(buildsUrl, token);
458
488
  const allBuilds = buildsData?.value || [];
459
489
  const prBuilds = allBuilds.filter(b => b.sourceVersion === mergeCommitId);
@@ -544,7 +574,7 @@ async function pollPrStatus(config) {
544
574
  const mergeStrategy = config.engine?.prMergeMethod === 'merge' ? 1 : config.engine?.prMergeMethod === 'rebase' ? 2 : 3; // 3 = squash
545
575
  const identityUrl = `${orgBase}/_apis/connectionData?api-version=7.1`;
546
576
  const identity = await adoFetch(identityUrl, token).catch(() => null);
547
- const autoCompleteUrl = `${orgBase}/${project.adoProject}/_apis/git/repositories/${project.repositoryId}/pullrequests/${prNum}?api-version=7.1`;
577
+ const autoCompleteUrl = `${orgBase}/${project.adoProject}/_apis/git/repositories/${encodedRepoId}/pullrequests/${prNum}?api-version=7.1`;
548
578
  await adoFetch(autoCompleteUrl, token, {
549
579
  method: 'PATCH',
550
580
  body: JSON.stringify({
@@ -599,8 +629,8 @@ async function pollPrHumanComments(config) {
599
629
  const token = await getAdoToken();
600
630
  if (!token) return;
601
631
 
602
- const totalUpdated = await forEachActivePr(config, token, async (project, pr, prNum, orgBase) => {
603
- const threadsUrl = `${orgBase}/${project.adoProject}/_apis/git/repositories/${project.repositoryId}/pullrequests/${prNum}/threads?api-version=7.1`;
632
+ const totalUpdated = await forEachActivePr(config, token, async (project, pr, prNum, orgBase, adoRepositoryId) => {
633
+ const threadsUrl = `${orgBase}/${project.adoProject}/_apis/git/repositories/${encodeURIComponent(adoRepositoryId)}/pullrequests/${prNum}/threads?api-version=7.1`;
604
634
  const threadsData = await adoFetch(threadsUrl, token);
605
635
  const threads = threadsData.value || [];
606
636
 
@@ -698,10 +728,16 @@ async function reconcilePrs(config) {
698
728
  let totalAdded = 0;
699
729
 
700
730
  for (const project of projects) {
701
- if (!project.adoOrg || !project.adoProject || !project.repositoryId) continue;
731
+ if (isGitHubProject(project)) continue;
732
+ if (!project.adoOrg || !project.adoProject) continue;
733
+ const adoRepositoryId = getAdoRepositoryId(project);
734
+ if (!adoRepositoryId) {
735
+ logMissingAdoRepository(project, 'ADO PR reconciliation');
736
+ continue;
737
+ }
702
738
 
703
739
  const orgBase = shared.getAdoOrgBase(project);
704
- const url = `${orgBase}/${project.adoProject}/_apis/git/repositories/${project.repositoryId}/pullrequests?searchCriteria.status=active&api-version=7.1`;
740
+ const url = `${orgBase}/${project.adoProject}/_apis/git/repositories/${encodeURIComponent(adoRepositoryId)}/pullrequests?searchCriteria.status=active&api-version=7.1`;
705
741
 
706
742
  let prData;
707
743
  try {
@@ -829,7 +865,12 @@ async function checkLiveReviewStatus(pr, project) {
829
865
  const orgBase = shared.getAdoOrgBase(project);
830
866
  const prNum = shared.getPrNumber(pr);
831
867
  if (!prNum) return null;
832
- const url = `${orgBase}/${project.adoProject}/_apis/git/repositories/${project.repositoryId}/pullrequests/${prNum}?api-version=7.1`;
868
+ const adoRepositoryId = getAdoRepositoryId(project);
869
+ if (!adoRepositoryId) {
870
+ logMissingAdoRepository(project, 'ADO live review check');
871
+ return null;
872
+ }
873
+ const url = `${orgBase}/${project.adoProject}/_apis/git/repositories/${encodeURIComponent(adoRepositoryId)}/pullrequests/${prNum}?api-version=7.1`;
833
874
  // SEC-02: use in-process adoFetch rather than a shell-out — keeps the bearer
834
875
  // token out of the process argv list where any local process could read it.
835
876
  // 4s timeout preserves the original request-cancellation semantics via AbortSignal.
@@ -868,7 +909,13 @@ async function checkLiveBuildAndConflict(pr, project) {
868
909
  const orgBase = shared.getAdoOrgBase(project);
869
910
  const prNum = shared.getPrNumber(pr);
870
911
  if (!prNum) return null;
871
- const repoBase = `${orgBase}/${project.adoProject}/_apis/git/repositories/${project.repositoryId}`;
912
+ const adoRepositoryId = getAdoRepositoryId(project);
913
+ if (!adoRepositoryId) {
914
+ logMissingAdoRepository(project, 'ADO live build/conflict check');
915
+ return null;
916
+ }
917
+ const encodedRepoId = encodeURIComponent(adoRepositoryId);
918
+ const repoBase = `${orgBase}/${project.adoProject}/_apis/git/repositories/${encodedRepoId}`;
872
919
  const prUrl = `${repoBase}/pullrequests/${prNum}?api-version=7.1`;
873
920
  // 4s timeout — same budget as checkLiveReviewStatus. This is a pre-dispatch
874
921
  // gate; we'd rather miss a freshness signal and fall back to cache than
@@ -889,7 +936,7 @@ async function checkLiveBuildAndConflict(pr, project) {
889
936
  if (mergeCommitId) {
890
937
  try {
891
938
  const mergeRef = encodeURIComponent(`refs/pull/${prNum}/merge`);
892
- const buildsUrl = `${orgBase}/${project.adoProject}/_apis/build/builds?branchName=${mergeRef}&repositoryId=${project.repositoryId}&repositoryType=TfsGit&$top=25&api-version=7.1`;
939
+ const buildsUrl = `${orgBase}/${project.adoProject}/_apis/build/builds?branchName=${mergeRef}&repositoryId=${encodedRepoId}&repositoryType=TfsGit&$top=25&api-version=7.1`;
893
940
  const buildsData = await adoFetch(buildsUrl, token, { timeout: 4000 });
894
941
  const allBuilds = buildsData?.value || [];
895
942
  const prBuilds = allBuilds.filter(b => b.sourceVersion === mergeCommitId);
@@ -940,9 +987,15 @@ async function fetchSinglePrBuildStatus(project, prNumber) {
940
987
  if (!token) return null;
941
988
 
942
989
  const orgBase = getAdoOrgBase(project);
943
- const repoBase = `${orgBase}/${project.adoProject}/_apis/git/repositories/${project.repositoryId}`;
990
+ const adoRepositoryId = getAdoRepositoryId(project);
991
+ if (!adoRepositoryId) {
992
+ logMissingAdoRepository(project, 'ADO single PR status fetch');
993
+ return null;
994
+ }
995
+ const encodedRepoId = encodeURIComponent(adoRepositoryId);
996
+ const repoBase = `${orgBase}/${project.adoProject}/_apis/git/repositories/${encodedRepoId}`;
944
997
  const mergeRef = encodeURIComponent(`refs/pull/${prNumber}/merge`);
945
- const buildsUrl = `${orgBase}/${project.adoProject}/_apis/build/builds?branchName=${mergeRef}&repositoryId=${project.repositoryId}&repositoryType=TfsGit&$top=25&api-version=7.1`;
998
+ const buildsUrl = `${orgBase}/${project.adoProject}/_apis/build/builds?branchName=${mergeRef}&repositoryId=${encodedRepoId}&repositoryType=TfsGit&$top=25&api-version=7.1`;
946
999
 
947
1000
  // Fetch PR metadata and builds in parallel
948
1001
  const [prData, buildsData] = await Promise.all([
@@ -1011,7 +1064,12 @@ const getAdoThrottleState = () => _adoThrottle.getState();
1011
1064
  * @returns {{ prNumber: number, url: string }|null}
1012
1065
  */
1013
1066
  async function findOpenPrOnBranch(project, branch) {
1014
- if (!project.adoOrg || !project.adoProject || !project.repositoryId || !branch) return null;
1067
+ if (!project.adoOrg || !project.adoProject || !branch) return null;
1068
+ const adoRepositoryId = getAdoRepositoryId(project);
1069
+ if (!adoRepositoryId) {
1070
+ logMissingAdoRepository(project, 'ADO branch PR lookup');
1071
+ return null;
1072
+ }
1015
1073
  if (isAdoThrottled()) {
1016
1074
  log('debug', `[ado] Skipping branch PR lookup for ${project.name || project.repoName || 'unknown project'}:${branch} — throttled`);
1017
1075
  return null;
@@ -1020,7 +1078,7 @@ async function findOpenPrOnBranch(project, branch) {
1020
1078
  if (!token) return null;
1021
1079
  const orgBase = shared.getAdoOrgBase(project);
1022
1080
  const sourceRef = encodeURIComponent(`refs/heads/${branch}`);
1023
- const url = `${orgBase}/${project.adoProject}/_apis/git/repositories/${project.repositoryId}/pullrequests?searchCriteria.status=active&searchCriteria.sourceRefName=${sourceRef}&api-version=7.1`;
1081
+ const url = `${orgBase}/${project.adoProject}/_apis/git/repositories/${encodeURIComponent(adoRepositoryId)}/pullrequests?searchCriteria.status=active&searchCriteria.sourceRefName=${sourceRef}&api-version=7.1`;
1024
1082
  const data = await adoFetch(url, token);
1025
1083
  const pr = (data.value || [])[0];
1026
1084
  if (!pr) return null;
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-04-29T15:03:46.217Z"
4
+ "cachedAt": "2026-04-29T17:18:42.809Z"
5
5
  }
@@ -111,6 +111,17 @@ function isRetryableFailureReason(reason = '', failureClass = '') {
111
111
  'invalid file path',
112
112
  'missing required',
113
113
  'validation failed',
114
+ 'auth failure',
115
+ 'authentication failed',
116
+ 'authentication failure',
117
+ 'unauthorized',
118
+ 'invalid api key',
119
+ 'please log in',
120
+ 'budget-exceeded',
121
+ 'budget exceeded',
122
+ 'budget cap exceeded',
123
+ 'max-budget-usd',
124
+ 'cost limit',
114
125
  ];
115
126
  return !nonRetryable.some(s => r.includes(s));
116
127
  }
@@ -552,12 +552,11 @@ function createStreamConsumer(ctx) {
552
552
  // inspect...") is progress text only — terminal text comes from non-tool
553
553
  // assistant messages or trailing deltas.
554
554
  let copilotMessageBuffer = '';
555
- let copilotTaskCompleteSeen = false;
556
555
 
557
556
  function _captureTaskComplete(summary, success = true) {
558
557
  if (typeof summary !== 'string' || !summary) return;
559
- copilotTaskCompleteSeen = true;
560
558
  copilotMessageBuffer = '';
559
+ ctx.pushText(summary);
561
560
  ctx.notifyTaskComplete(summary, success !== false);
562
561
  }
563
562
 
@@ -579,7 +578,6 @@ function createStreamConsumer(ctx) {
579
578
  }
580
579
 
581
580
  if (obj.type === 'assistant.message_delta' && typeof obj.data?.deltaContent === 'string') {
582
- if (copilotTaskCompleteSeen) return;
583
581
  copilotMessageBuffer += obj.data.deltaContent;
584
582
  ctx.pushText(copilotMessageBuffer);
585
583
  return;
@@ -629,7 +627,6 @@ function createStreamConsumer(ctx) {
629
627
 
630
628
  function reset() {
631
629
  copilotMessageBuffer = '';
632
- copilotTaskCompleteSeen = false;
633
630
  }
634
631
 
635
632
  return { consume, reset };
package/engine.js CHANGED
@@ -3337,11 +3337,30 @@ let tickCount = 0;
3337
3337
  // In-memory cache of plan filenames confirmed completed — avoids redundant
3338
3338
  // checkPlanCompletion calls. Cleared automatically on engine restart.
3339
3339
  const completedPlanCache = new Set();
3340
+ let lastWatchCheckAt = 0;
3341
+ let lastPrStatusPollAt = 0;
3342
+ let lastPrCommentsPollAt = 0;
3340
3343
 
3341
3344
  let tickRunning = false;
3342
3345
  let _tickStartedAt = 0;
3343
3346
  const TICK_TIMEOUT_MS = 300000; // 5 min — force-release tick lock if stuck
3344
3347
 
3348
+ function _pollIntervalMsFromTicks(ticks, tickIntervalMs) {
3349
+ const normalizedTicks = Math.max(1, Number(ticks) || 1);
3350
+ const normalizedTickInterval = Math.max(1, Number(tickIntervalMs) || ENGINE_DEFAULTS.tickInterval);
3351
+ return normalizedTicks * normalizedTickInterval;
3352
+ }
3353
+
3354
+ function _shouldRunPeriodicPhase(now, lastRunAt, intervalMs) {
3355
+ const current = Number(now);
3356
+ const previous = Number(lastRunAt) || 0;
3357
+ const interval = Math.max(1, Number(intervalMs) || 1);
3358
+ if (!Number.isFinite(current)) return false;
3359
+ if (!previous || !Number.isFinite(previous)) return true;
3360
+ if (current < previous) return true;
3361
+ return current - previous >= interval;
3362
+ }
3363
+
3345
3364
  async function tick() {
3346
3365
  if (tickRunning) {
3347
3366
  if (_tickStartedAt && Date.now() - _tickStartedAt > TICK_TIMEOUT_MS) {
@@ -3375,6 +3394,8 @@ async function tickInner() {
3375
3394
 
3376
3395
  const config = getConfig();
3377
3396
  tickCount++;
3397
+ const now = Date.now();
3398
+ const tickIntervalMs = Math.max(1, Number(config.engine?.tickInterval) || ENGINE_DEFAULTS.tickInterval);
3378
3399
  _failedRefCache.clear(); // Reset per-tick failed-ref cache
3379
3400
 
3380
3401
  // Helper: run a phase, log + continue on error
@@ -3402,8 +3423,10 @@ async function tickInner() {
3402
3423
  safe('runCleanup', () => runCleanup(config));
3403
3424
  }
3404
3425
 
3405
- // 2.55. Check persistent watches (every 3 ticks = ~3 minutes)
3406
- if (tickCount % 3 === 0) {
3426
+ // 2.55. Check persistent watches (3 tick-equivalents, default ~3 minutes)
3427
+ const watchPollIntervalMs = _pollIntervalMsFromTicks(3, tickIntervalMs);
3428
+ if (_shouldRunPeriodicPhase(now, lastWatchCheckAt, watchPollIntervalMs)) {
3429
+ lastWatchCheckAt = now;
3407
3430
  safe('checkWatches', () => {
3408
3431
  const { checkWatches } = require('./engine/watches');
3409
3432
  const projects = getProjects(config);
@@ -3432,10 +3455,14 @@ async function tickInner() {
3432
3455
  Number(config.engine?.prPollCommentsEvery ?? config.engine?.adoPollCommentsEvery) || ENGINE_DEFAULTS.prPollCommentsEvery
3433
3456
  );
3434
3457
 
3435
- // 2.6. Poll PR status: build, review, merge (every prPollStatusEvery ticks, default ~12 minutes)
3458
+ // 2.6. Poll PR status: build, review, merge (every prPollStatusEvery tick-equivalents, default ~12 minutes)
3436
3459
  // Awaited so PR state is consistent before discoverWork reads it
3437
3460
  // Also re-polls early if previous tick had ADO auth failures (stale build status recovery)
3438
- if (tickCount % prPollStatusEvery === 0 || needsAdoPollRetry()) {
3461
+ const prPollStatusIntervalMs = _pollIntervalMsFromTicks(prPollStatusEvery, tickIntervalMs);
3462
+ const prStatusPollDue = _shouldRunPeriodicPhase(now, lastPrStatusPollAt, prPollStatusIntervalMs);
3463
+ const adoPollRetryDue = needsAdoPollRetry();
3464
+ if (prStatusPollDue || adoPollRetryDue) {
3465
+ lastPrStatusPollAt = now;
3439
3466
  // Build promise array — enabled+unthrottled polls run concurrently via Promise.allSettled
3440
3467
  const statusPolls = [];
3441
3468
  if (adoPollEnabled && !isAdoThrottled()) {
@@ -3475,8 +3502,11 @@ async function tickInner() {
3475
3502
  } catch (err) { log('warn', `Plan completion check error: ${err?.message || err}`); }
3476
3503
  }
3477
3504
 
3478
- // 2.7. Poll PR threads for human comments (every prPollCommentsEvery ticks, default ~12 minutes)
3479
- if (tickCount % prPollCommentsEvery === 0) {
3505
+ const prPollCommentsIntervalMs = _pollIntervalMsFromTicks(prPollCommentsEvery, tickIntervalMs);
3506
+
3507
+ // 2.7. Poll PR threads for human comments (every prPollCommentsEvery tick-equivalents, default ~12 minutes)
3508
+ if (_shouldRunPeriodicPhase(now, lastPrCommentsPollAt, prPollCommentsIntervalMs)) {
3509
+ lastPrCommentsPollAt = now;
3480
3510
  // Build promise array — enabled+unthrottled comment polls run concurrently via Promise.allSettled
3481
3511
  const commentPolls = [];
3482
3512
  if (adoPollEnabled && !isAdoThrottled()) {
@@ -3923,6 +3953,7 @@ module.exports = {
3923
3953
 
3924
3954
  // Tick
3925
3955
  tick,
3956
+ _pollIntervalMsFromTicks, _shouldRunPeriodicPhase, // exported for testing
3926
3957
  };
3927
3958
 
3928
3959
  // ─── Entrypoint ─────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1623",
3
+ "version": "0.1.1625",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"