@yemi33/minions 0.1.2122 → 0.1.2124
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/dashboard/js/refresh.js +1 -1
- package/dashboard/js/render-work-items.js +100 -1
- package/dashboard/styles.css +2 -0
- package/docs/README.md +1 -0
- package/docs/harness-mode.md +92 -0
- package/docs/workspace-manifests.md +104 -0
- package/engine/ado.js +9 -0
- package/engine/github.js +4 -1
- package/engine/harness.js +592 -0
- package/engine/lifecycle.js +91 -0
- package/engine/scheduler.js +40 -3
- package/engine/shared.js +269 -2
- package/engine.js +91 -15
- package/package.json +1 -1
package/dashboard/js/refresh.js
CHANGED
|
@@ -15,8 +15,71 @@ const _WI_ENRICHMENT_FIELDS = [
|
|
|
15
15
|
'_pr', '_prUrl', '_pendingReason', '_skipReason', '_blockedBy',
|
|
16
16
|
'_humanFeedback', '_reopened', '_managedSpawnPartial', '_securityFlag',
|
|
17
17
|
'_artifacts', 'referencesCount', 'acceptanceCriteriaCount',
|
|
18
|
+
// W-mq08kuog001110a6 — _preDispatchEval IS persisted to disk by
|
|
19
|
+
// engine/dispatch.js#_persistInvalidWorkItem, but the slim /api/status
|
|
20
|
+
// overlay may drop it on later passes. Carry it across the overlay so
|
|
21
|
+
// the Needs-Attention badge survives /state polls.
|
|
22
|
+
'_preDispatchEval',
|
|
18
23
|
];
|
|
19
24
|
|
|
25
|
+
// W-mq08kuog001110a6 — Needs-Attention badge for stuck pending work items.
|
|
26
|
+
//
|
|
27
|
+
// A pending WI is "needs attention" when the engine is intentionally refusing
|
|
28
|
+
// to dispatch it and a human (or follow-up dispatch) has to unblock it.
|
|
29
|
+
// Surfaces existing engine state (_preDispatchEval / _pendingReason) — no
|
|
30
|
+
// new fields, no dispatcher behavior change.
|
|
31
|
+
//
|
|
32
|
+
// Triggers:
|
|
33
|
+
// * _preDispatchEval.valid === false (pre-dispatch acceptance gate rejected)
|
|
34
|
+
// * _pendingReason in { pr_not_found, no_agent, budget_exceeded, dependency_unmet }
|
|
35
|
+
//
|
|
36
|
+
// Does NOT trigger for transient reasons that auto-clear:
|
|
37
|
+
// * cooldown / retry_cooldown — wait it out
|
|
38
|
+
// * already_dispatched — engine reconciles on next tick
|
|
39
|
+
// * branch_locked — wait for the holding dispatch
|
|
40
|
+
//
|
|
41
|
+
// Returns { kind, short, full } when the item needs attention, else null.
|
|
42
|
+
// Pure helper (no DOM / escapeHtml deps) so it can be unit-tested in Node.
|
|
43
|
+
function needsAttentionInfo(item) {
|
|
44
|
+
if (!item || item.status !== 'pending') return null;
|
|
45
|
+
|
|
46
|
+
if (item._preDispatchEval && item._preDispatchEval.valid === false) {
|
|
47
|
+
var rawReason = String(item._preDispatchEval.reason || '(no reason recorded)');
|
|
48
|
+
var firstLine = rawReason.split(/\r?\n/, 1)[0] || rawReason;
|
|
49
|
+
return {
|
|
50
|
+
kind: 'pre_dispatch_eval',
|
|
51
|
+
short: 'Pre-dispatch evaluation rejected: ' + firstLine,
|
|
52
|
+
full: rawReason,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
var reason = item._pendingReason;
|
|
57
|
+
if (!reason) return null;
|
|
58
|
+
|
|
59
|
+
if (reason === 'pr_not_found') {
|
|
60
|
+
var prRef = item.prNumber || item.targetPr || item.pr_id || '?';
|
|
61
|
+
var prMsg = 'Target PR not tracked: PR #' + prRef + '. The PR was likely merged/closed or never linked.';
|
|
62
|
+
return { kind: 'pr_not_found', short: prMsg, full: prMsg };
|
|
63
|
+
}
|
|
64
|
+
if (reason === 'no_agent') {
|
|
65
|
+
var noAgentMsg = 'No agent assigned and routing.md has no match for type=' + (item.type || 'unknown') + '.';
|
|
66
|
+
return { kind: 'no_agent', short: noAgentMsg, full: noAgentMsg };
|
|
67
|
+
}
|
|
68
|
+
if (reason === 'budget_exceeded') {
|
|
69
|
+
var agent = item.agent || item.dispatched_to || '<unknown>';
|
|
70
|
+
var budgetMsg = 'Assigned agent ' + agent + ' is over its maxBudgetUsd cap.';
|
|
71
|
+
return { kind: 'budget_exceeded', short: budgetMsg, full: budgetMsg };
|
|
72
|
+
}
|
|
73
|
+
if (reason === 'dependency_unmet') {
|
|
74
|
+
var deps = Array.isArray(item.depends_on) && item.depends_on.length
|
|
75
|
+
? item.depends_on.join(', ')
|
|
76
|
+
: '<unknown>';
|
|
77
|
+
var depMsg = 'Waiting on dependencies: ' + deps + '. (One or more are not yet done.)';
|
|
78
|
+
return { kind: 'dependency_unmet', short: depMsg, full: depMsg };
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
20
83
|
// Pull each project's work-items.json straight off disk through
|
|
21
84
|
// /state/projects/<name>/work-items.json. The endpoint is a static-file
|
|
22
85
|
// passthrough with mtime+size ETag — there is no server-side cache in the
|
|
@@ -129,6 +192,15 @@ function wiRow(item) {
|
|
|
129
192
|
'<td>' + priBadge(item.priority) + '</td>' +
|
|
130
193
|
'<td>' + statusBadge(item.status || 'pending') +
|
|
131
194
|
(item._reopened ? ' <span style="font-size:var(--text-xs);color:var(--purple);margin-left:4px" title="This item was reopened from a previously completed state">reopened</span>' : '') +
|
|
195
|
+
(function() {
|
|
196
|
+
// W-mq08kuog001110a6 — Needs-Attention badge for stuck pending WIs.
|
|
197
|
+
// Click anywhere on the row to open the modal where the full reason
|
|
198
|
+
// is rendered under "Why this is blocked".
|
|
199
|
+
var info = needsAttentionInfo(item);
|
|
200
|
+
if (!info) return '';
|
|
201
|
+
var tooltip = (info.short || '').replace(/\s+/g, ' ').slice(0, 120);
|
|
202
|
+
return ' <span class="pr-badge needs-attention" style="font-size:var(--text-xs);margin-left:4px" title="' + escapeHtml(tooltip) + ' — click row for full reason">⚠ Needs attention</span>';
|
|
203
|
+
})() +
|
|
132
204
|
(item._pendingReason && item.status === 'pending' && item._pendingReason !== 'already_dispatched' ? ' <span style="font-size:var(--text-xs);color:var(--muted);margin-left:4px" title="Pending reason: ' + escapeHtml(item._pendingReason) + '">' + escapeHtml(item._pendingReason.replace(/_/g, ' ')) + '</span>' : '') +
|
|
133
205
|
(item._pendingReason === 'already_dispatched' && item.status === 'pending' ? ' <span style="font-size:var(--text-xs);color:var(--blue);margin-left:4px" title="In dispatch queue, waiting to be assigned">queued</span>' : '') +
|
|
134
206
|
(item._managedSpawnPartial && Array.isArray(item._managedSpawnPartial.failed) && item._managedSpawnPartial.failed.length
|
|
@@ -597,7 +669,28 @@ function _wiRenderDetail(item) {
|
|
|
597
669
|
(item._reopened ? ' <span style="font-size:var(--text-xs);color:var(--purple);margin-left:4px" title="This item was reopened from a previously completed state">reopened</span>' : '') + ' ' +
|
|
598
670
|
'<span class="dispatch-type ' + (item.type || 'implement') + '">' + escapeHtml(item.type || 'implement') + '</span>' +
|
|
599
671
|
'<span class="prd-item-priority ' + (item.priority || '') + '">' + escapeHtml(item.priority || 'medium') + '</span>' +
|
|
672
|
+
(function() {
|
|
673
|
+
// W-mq08kuog001110a6 — Needs-Attention chip in the detail modal header.
|
|
674
|
+
var info = needsAttentionInfo(item);
|
|
675
|
+
if (!info) return '';
|
|
676
|
+
return ' <span class="pr-badge needs-attention" style="font-size:var(--text-xs);margin-left:4px" title="See "Why this is blocked" below">⚠ Needs attention</span>';
|
|
677
|
+
})() +
|
|
600
678
|
'</div>';
|
|
679
|
+
// W-mq08kuog001110a6 — "Why this is blocked" section, rendered FIRST so the
|
|
680
|
+
// human eye lands on the unblocking reason before scrolling through agent,
|
|
681
|
+
// source, dates, etc. _preDispatchEval reasons are evaluator prose and can
|
|
682
|
+
// be multi-line — render verbatim in a <pre> to preserve newlines.
|
|
683
|
+
(function() {
|
|
684
|
+
var info = needsAttentionInfo(item);
|
|
685
|
+
if (!info) return;
|
|
686
|
+
html += field(
|
|
687
|
+
'Why this is blocked',
|
|
688
|
+
'<div style="border-left:3px solid var(--yellow);padding:8px 10px;background:rgba(210,153,34,0.08);border-radius:var(--radius-sm)">' +
|
|
689
|
+
'<div style="font-size:var(--text-sm);color:var(--muted);margin-bottom:4px">reason: <code>' + escapeHtml(info.kind) + '</code></div>' +
|
|
690
|
+
'<pre style="margin:0;font-size:var(--text-sm);white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono, monospace)">' + escapeHtml(info.full) + '</pre>' +
|
|
691
|
+
'</div>'
|
|
692
|
+
);
|
|
693
|
+
})();
|
|
601
694
|
// Description: rendered from the FULL record once it's hydrated. The /api/status
|
|
602
695
|
// slice drops `description` to keep the SPA payload <500KB; on first paint we
|
|
603
696
|
// either render the title as a placeholder or, when description is already
|
|
@@ -788,4 +881,10 @@ function openInboxNote(filename) {
|
|
|
788
881
|
switchPage('inbox');
|
|
789
882
|
}
|
|
790
883
|
|
|
791
|
-
|
|
884
|
+
if (typeof window !== 'undefined') {
|
|
885
|
+
window.MinionsWork = { wiRow, renderWorkItems, editWorkItem, submitWorkItemEdit, deleteWorkItem, archiveWorkItem, toggleWorkItemArchive, retryWorkItem, wiPrev, wiNext, feedbackWorkItem, submitFeedback, openCreateWorkItemModal, openWorkItemDetail, openAllWorkItems, viewAgentOutput, openInboxNote, needsAttentionInfo };
|
|
886
|
+
}
|
|
887
|
+
// exported for testing — pure helpers safe to require under Node (no DOM deps).
|
|
888
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
889
|
+
module.exports = { needsAttentionInfo };
|
|
890
|
+
}
|
package/dashboard/styles.css
CHANGED
|
@@ -322,6 +322,8 @@
|
|
|
322
322
|
.pr-badge.no-build { background: var(--surface); color: var(--muted); border: 1px solid var(--border); }
|
|
323
323
|
.pr-badge.build-stale { background: rgba(210,153,34,0.15); color: var(--orange); border: 1px dashed var(--orange); }
|
|
324
324
|
.pr-badge.build-escalated { background: rgba(248,81,73,0.25); color: var(--red); border: 2px solid var(--red); font-weight: 600; }
|
|
325
|
+
/* W-mq08kuog001110a6 — Needs-Attention chip for stuck pending work items. */
|
|
326
|
+
.pr-badge.needs-attention { background: rgba(210,153,34,0.15); color: var(--yellow); border: 1px solid var(--yellow); font-weight: 600; cursor: help; }
|
|
325
327
|
.error-details-btn { font-size: var(--text-xs); padding: var(--space-1) var(--space-3); margin-left: var(--space-2); background: rgba(248,81,73,0.15); color: var(--red); border: 1px solid var(--red); border-radius: var(--radius-lg); cursor: pointer; font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; }
|
|
326
328
|
.error-details-btn:hover { background: rgba(248,81,73,0.3); }
|
|
327
329
|
.pr-empty { color: var(--muted); font-style: italic; font-size: var(--text-md); padding: var(--space-6) 0; }
|
package/docs/README.md
CHANGED
|
@@ -36,6 +36,7 @@ Architecture, design proposals, and lifecycle references for people working on t
|
|
|
36
36
|
- [slim-ux/architecture-suggestions.md](slim-ux/architecture-suggestions.md) — Slim-UX follow-up architecture suggestions paired with `concepts.md`.
|
|
37
37
|
- [team-memory.md](team-memory.md) — Per-agent memory layer (`knowledge/agents/<id>.md`) and the consolidation/routing rules that populate it from `notes/inbox/`.
|
|
38
38
|
- [watches.md](watches.md) — Persistent monitoring jobs (`engine/watches.json`): target-type registry, conditions, follow-up actions, and the `watches.d/` plugin folder.
|
|
39
|
+
- [workspace-manifests.md](workspace-manifests.md) — Declarative per-agent permission scoping: `allowed_tools` / `allowed_repos` / `allowed_external_urls` / `memory_scope`, dispatch-time repo gate, and runtime `--allowedTools` narrowing.
|
|
39
40
|
|
|
40
41
|
## Operations
|
|
41
42
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Tri-Agent Harness Mode
|
|
2
|
+
|
|
3
|
+
> Status: opt-in feature flag on scheduled tasks (`harness_mode: "tri_agent"`).
|
|
4
|
+
> Shipped: W-mq07a9gf000jbc2b. Module: [`engine/harness.js`](../engine/harness.js).
|
|
5
|
+
|
|
6
|
+
## What it is
|
|
7
|
+
|
|
8
|
+
A way to turn one schedule firing into a coordinated **Planner → Generator → Evaluator** trio that iterates on a shared on-disk artifact until the artifact meets a rubric or hits an iteration cap. Useful for "produce a piece of work, then improve it" loops where a single agent call would either underspecify the task or produce uneven quality.
|
|
9
|
+
|
|
10
|
+
The three roles in order:
|
|
11
|
+
|
|
12
|
+
1. **Planner** (`ask` type, read-only) — reads the rubric, writes a short plan into the mission directory.
|
|
13
|
+
2. **Generator** (defaults to `ask`, inherits `sched.type`) — produces the artifact at `<MINIONS_DIR>/engine/harness/<missionId>/artifact.md` per the plan.
|
|
14
|
+
3. **Evaluator** (`ask`, read-only) — scores the artifact against the rubric and reports a verdict.
|
|
15
|
+
|
|
16
|
+
If the evaluator's verdict score is below `harness_threshold` (and the iteration cap hasn't been hit), the engine appends a fresh `Generator → Evaluator` pair carrying the evaluator's feedback in the next generator's prompt. Loop continues until pass or cap.
|
|
17
|
+
|
|
18
|
+
## Config schema (add to a schedule in `config.json`)
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"id": "weekly-design-review",
|
|
23
|
+
"title": "Tri-agent design review",
|
|
24
|
+
"cron": "0 9 * * MON",
|
|
25
|
+
"type": "ask",
|
|
26
|
+
"harness_mode": "tri_agent",
|
|
27
|
+
"harness_rubric": "Score 0-1. 1.0 = all sections complete with code examples. 0 = missing sections.",
|
|
28
|
+
"harness_threshold": 0.7,
|
|
29
|
+
"harness_max_iterations": 5
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
| Field | Required | Default | Notes |
|
|
34
|
+
|--------------------------|----------|---------|-----------------------------------------------------------------------|
|
|
35
|
+
| `harness_mode` | yes | — | Must equal `"tri_agent"` to enable. Any other value falls back to plain scheduled work. |
|
|
36
|
+
| `harness_rubric` | yes | — | Non-empty string. Injected into every role's prompt. The evaluator scores against this. |
|
|
37
|
+
| `harness_threshold` | no | `0.7` | Number in `(0, 1]`. Verdict score `>= threshold` = pass; `<` = iterate. |
|
|
38
|
+
| `harness_max_iterations` | no | `5` | Positive integer, capped at `20`. Counts generator iterations; planner is iteration 1. |
|
|
39
|
+
|
|
40
|
+
Invalid harness config logs a warning and **skips the firing without recording a schedule run**, so fixing the config and waiting for the next cron tick is enough to recover — no manual reset needed.
|
|
41
|
+
|
|
42
|
+
## Lifecycle
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
cron fires
|
|
46
|
+
└─ scheduler.discoverScheduledWork detects harness_mode === 'tri_agent'
|
|
47
|
+
└─ validateHarnessConfig (skip+warn on failure)
|
|
48
|
+
└─ createTriAgentMission → 3 work items
|
|
49
|
+
├─ Planner (iteration 1)
|
|
50
|
+
├─ Generator (iteration 1, depends on Planner)
|
|
51
|
+
└─ Evaluator (iteration 1, depends on Generator)
|
|
52
|
+
│
|
|
53
|
+
▼ (on success)
|
|
54
|
+
lifecycle.runPostCompletionHooks
|
|
55
|
+
└─ handleHarnessIterationResult
|
|
56
|
+
└─ parseEvaluatorVerdict + shouldIterateAgain
|
|
57
|
+
└─ if iterate: append Generator + Evaluator (iteration N+1)
|
|
58
|
+
└─ next tick dispatches them
|
|
59
|
+
└─ if pass / cap / inconclusive: mission terminal
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Artifact layout
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
<MINIONS_DIR>/engine/harness/<missionId>/
|
|
66
|
+
└─ artifact.md ← Generator writes here, Evaluator reads here
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Mission ID format: `<scheduleId>-<unixMs>-<rand6>`. The mission directory is the contract — agents in all 3 roles get the same path injected into their prompts.
|
|
70
|
+
|
|
71
|
+
## Evaluator verdict protocol
|
|
72
|
+
|
|
73
|
+
The evaluator can signal pass/fail/score either way:
|
|
74
|
+
|
|
75
|
+
- **Preferred (structured):** include the fields in the completion report sidecar:
|
|
76
|
+
```json
|
|
77
|
+
{ "harness_pass": true, "harness_score": 0.82, "harness_feedback": "all sections present" }
|
|
78
|
+
```
|
|
79
|
+
- **Fallback (text):** include `Score: 0.82` and `PASS` / `FAIL` in the summary. Structured fields win when both present. `FAIL` takes precedence when both `PASS` and `FAIL` appear in the text.
|
|
80
|
+
|
|
81
|
+
If neither signal is parseable, the harness treats the verdict as inconclusive and stops iterating (`shouldIterateAgain` returns false) to avoid an infinite loop driven by a silent agent.
|
|
82
|
+
|
|
83
|
+
## Dedup behavior (engine.js)
|
|
84
|
+
|
|
85
|
+
Within a single tick the standard scheduled-work dedup is keyed by `_scheduleId`, which would collapse the harness trio to one item. The harness trio share a `_missionId`; engine.js snapshots active mission IDs **before** the dedup loop so all 3 land together, while plain scheduled items keep the original `_scheduleId` dedup.
|
|
86
|
+
|
|
87
|
+
## Operational notes
|
|
88
|
+
|
|
89
|
+
- Tri-agent items are **schedule-driven** — there's no manual "fire a harness mission" entry point. Add a schedule with `harness_mode: "tri_agent"` to opt in.
|
|
90
|
+
- Iteration pairs always reuse the original mission's artifact path, threshold, max-iterations, and rubric. The evaluator's verdict feedback is appended to the next generator's prompt.
|
|
91
|
+
- Mission state lives entirely on disk: the work-items.json trio + the artifact file. No new DB tables.
|
|
92
|
+
- Each iteration's evaluator is a separate work item, so dispatch retries, cooldowns, and steering apply normally to every role.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Workspace Manifests — Declarative Permission Scoping for Agents
|
|
2
|
+
|
|
3
|
+
> **Status:** Implemented in W-mq07avbk000m5543. Inspired by OpenAI's AGENTS.md / Workspace Manifest primitive (April 2026, [link](https://openai.com/index/the-next-evolution-of-the-agents-sdk/)).
|
|
4
|
+
|
|
5
|
+
Workspace manifests move per-agent tool / repo / URL / memory permissions from implicit system-prompt text to **machine-readable, runtime-enforced declarations**. Each agent's manifest lives on its config entry and is enforced at dispatch time and spawn time without requiring agents to read or honour prompt language.
|
|
6
|
+
|
|
7
|
+
## Schema
|
|
8
|
+
|
|
9
|
+
A manifest lives on each agent definition under `workspace_manifest`. All fields are optional; any missing field is **permissive** (no restriction), so agents without a manifest behave exactly as they did before this feature shipped.
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"agents": {
|
|
14
|
+
"ralph": {
|
|
15
|
+
"name": "Ralph",
|
|
16
|
+
"role": "Engineer",
|
|
17
|
+
"skills": ["implementation", "testing"],
|
|
18
|
+
"workspace_manifest": {
|
|
19
|
+
"allowed_tools": ["Edit", "Read", "Bash", "Grep", "Glob"],
|
|
20
|
+
"allowed_repos": ["github:yemi33/minions", "opg-microsoft/minions"],
|
|
21
|
+
"allowed_external_urls": ["github.com", "*.npmjs.com"],
|
|
22
|
+
"memory_scope": "shared"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
| Field | Type | Default | Semantics |
|
|
30
|
+
| ----- | ---- | ------- | --------- |
|
|
31
|
+
| `allowed_tools` | `string[]` \| `null` | `null` (permissive) | Canonical tool-name whitelist (e.g. `Edit`, `Read`, `Bash`). Empty array = deny all. Case-sensitive. Merged into the runtime adapter's `--allowedTools` flag at spawn time. |
|
|
32
|
+
| `allowed_repos` | `string[]` \| `null` | `null` (permissive) | Canonical PR-scope (`github:owner/repo`, `ado:org/proj/repo`) or bare `owner/repo`. Empty array = deny all. Case-insensitive. Enforced at dispatch time — out-of-scope dispatch fails with `failure_class: 'workspace-manifest-repo-forbidden'`. |
|
|
33
|
+
| `allowed_external_urls` | `string[]` \| `null` | `null` (permissive) | Host allow-list for `web_fetch` / `web_search`. Bare host (`github.com`) = exact match; `*.example.com` = wildcard subdomain **and** apex. Empty array = deny all. Currently advisory — see "Enforcement points" below. |
|
|
34
|
+
| `memory_scope` | `'private'` \| `'shared'` \| `'read-only-shared'` | `'shared'` | What slice of team knowledge the agent can read/write. Currently exposed via `shared.agentMemoryScope(agent)` for callers that want to gate inbox writes; full enforcement at the consolidation layer is future work. |
|
|
35
|
+
|
|
36
|
+
## Default behaviour & backward compatibility
|
|
37
|
+
|
|
38
|
+
The defaults are deliberately permissive so the manifest can be rolled out incrementally:
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
// engine/shared.js
|
|
42
|
+
const WORKSPACE_MANIFEST_DEFAULTS = {
|
|
43
|
+
allowed_tools: null, // no tool restriction
|
|
44
|
+
allowed_repos: null, // no repo restriction
|
|
45
|
+
allowed_external_urls: null, // no URL restriction
|
|
46
|
+
memory_scope: 'shared',
|
|
47
|
+
};
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
A config that never mentions `workspace_manifest` produces the exact same dispatch and spawn behaviour as the pre-manifest engine. Built-in agents (Ripley, Dallas, Lambert, Rebecca, Ralph) ship without manifests by default — manifests are an opt-in per-agent hardening primitive, not a fleet-wide default.
|
|
51
|
+
|
|
52
|
+
## Enforcement points
|
|
53
|
+
|
|
54
|
+
| Phase | Location | What it does |
|
|
55
|
+
| ----- | -------- | ------------ |
|
|
56
|
+
| **Dispatch — repo gate** | `engine.js spawnAgent()` (right after project resolution) | Calls `shared.agentCanUseRepo(agent, project)`. Mismatch → `completeDispatch(... FAILURE_CLASS.WORKSPACE_MANIFEST_REPO, agentRetryable: false)` and `cleanupTempAgent` runs. Non-retryable because the structural answer is "widen the manifest or pick a different agent". |
|
|
57
|
+
| **Spawn — tool merge** | `engine.js spawnAgent()` → `_buildAgentSpawnFlags(..., allowedTools)` | `shared.mergeManifestAllowedTools(claudeConfig.allowedTools, manifest.allowed_tools)` produces the intersection of the runtime baseline and the manifest list. Result is passed as `--allowedTools <csv>` to Claude / Copilot / Codex, so the CLI itself enforces the narrowed surface. Empty manifest list (`[]`) = deny-all. Same merge runs on the steering-resume codepath. |
|
|
58
|
+
| **Agent context** | Future work (playbook.js) | Manifest can be surfaced into the agent prompt so the agent sees its declared scope. Today, `shared.resolveAgentManifest(agent)` returns the resolved struct any caller can read. |
|
|
59
|
+
| **URL fetch** | Advisory today | `shared.agentCanFetchUrl(agent, url)` is available for runtime intercepts. Minions itself does not currently intercept `web_fetch` inside the spawned CLI subprocess; a future runtime adapter hook can wire this. |
|
|
60
|
+
| **Memory scope** | Advisory today | `shared.agentMemoryScope(agent)` is available for consolidation / playbook / inbox callers. Semantics: `private` = agent only sees its own `knowledge/agents/<id>.md`; `shared` = full team knowledge (current default); `read-only-shared` = reads shared memory but should not write inbox/notes. |
|
|
61
|
+
|
|
62
|
+
## Helpers (in `engine/shared.js`)
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
const { MEMORY_SCOPES, WORKSPACE_MANIFEST_DEFAULTS,
|
|
66
|
+
validateWorkspaceManifest, resolveAgentManifest,
|
|
67
|
+
agentCanUseRepo, agentCanUseTool, agentCanFetchUrl, agentMemoryScope,
|
|
68
|
+
mergeManifestAllowedTools, formatManifestRejection } = require('./engine/shared');
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- `validateWorkspaceManifest(manifest)` → `{ ok, errors }`. `null`/`undefined` is valid (uses defaults).
|
|
72
|
+
- `resolveAgentManifest(agent, config?)` → fresh copy of `WORKSPACE_MANIFEST_DEFAULTS` overlaid with the agent's `workspace_manifest`. Malformed manifest silently falls back to defaults; surface the error via `validateWorkspaceManifest` at config-load time.
|
|
73
|
+
- `agentCanUseRepo(agent, projectOrString)` → `bool`. Accepts a project object or a string identifier (`github:owner/repo`, `ado:org/proj/repo`, or bare `owner/repo`). Case-insensitive.
|
|
74
|
+
- `agentCanUseTool(agent, toolName)` → `bool`. Case-sensitive.
|
|
75
|
+
- `agentCanFetchUrl(agent, url)` → `bool`. Wildcard `*.example.com` matches subdomains **and** apex.
|
|
76
|
+
- `agentMemoryScope(agent)` → one of `MEMORY_SCOPES`. Unknown values fall back to `'shared'`.
|
|
77
|
+
- `mergeManifestAllowedTools(baselineCsv, manifestArray)` → merged CSV. Intersection semantics: `null` manifest = baseline unchanged; empty array = deny-all; empty baseline + manifest = manifest as ceiling.
|
|
78
|
+
- `formatManifestRejection({ agentId, kind, target, allowed })` → structured human-readable rejection string used by the dispatch repo gate.
|
|
79
|
+
|
|
80
|
+
## Failure classes (`shared.FAILURE_CLASS`)
|
|
81
|
+
|
|
82
|
+
| Code | Meaning |
|
|
83
|
+
| ---- | ------- |
|
|
84
|
+
| `workspace-manifest-repo-forbidden` | Dispatch routed an agent to a project not in its `allowed_repos`. Non-retryable. |
|
|
85
|
+
| `workspace-manifest-tool-forbidden` | Reserved for future tool-call intercepts. Non-retryable as-is. |
|
|
86
|
+
| `workspace-manifest-url-forbidden` | Reserved for future URL-fetch intercepts. Non-retryable as-is. |
|
|
87
|
+
|
|
88
|
+
## Audit trail
|
|
89
|
+
|
|
90
|
+
Because rejections route through the standard `completeDispatch(... ERROR ...)` path, the existing dispatch + inbox + dashboard timeline machinery already surfaces every manifest rejection. No new state file or event topic was added. Per-agent audit of what each minion actually touched can be derived from existing `engine/dispatch.json` records filtered by `failure_class === 'workspace-manifest-repo-forbidden'`.
|
|
91
|
+
|
|
92
|
+
## Rollout guidance
|
|
93
|
+
|
|
94
|
+
1. Leave defaults (no manifests) until you have at least one agent that should be locked down (e.g. a contract agent that should only ever touch one repo, or an external collaborator's agent).
|
|
95
|
+
2. Set `workspace_manifest.allowed_repos` first — it's the highest-impact gate and is fully enforced at dispatch time.
|
|
96
|
+
3. Add `workspace_manifest.allowed_tools` for agents whose role doesn't need `Bash` / `WebFetch` / `WebSearch`. The runtime CLI enforces this, so the narrowing is hard-edged.
|
|
97
|
+
4. Treat `allowed_external_urls` and `memory_scope` as documentation today; they become hard gates as we add runtime intercepts.
|
|
98
|
+
|
|
99
|
+
## Related
|
|
100
|
+
|
|
101
|
+
- `engine/shared.js` — manifest helpers and `FAILURE_CLASS`.
|
|
102
|
+
- `engine.js spawnAgent()` — dispatch-time enforcement.
|
|
103
|
+
- `test/unit/workspace-manifest.test.js` — full validator + gate + integration tests.
|
|
104
|
+
- [`completion-reports.md`](completion-reports.md) — how the dispatch failure surfaces in the standard completion contract.
|
package/engine/ado.js
CHANGED
|
@@ -975,6 +975,15 @@ async function forEachActivePr(config, token, callback) {
|
|
|
975
975
|
continue;
|
|
976
976
|
}
|
|
977
977
|
|
|
978
|
+
// Per-project throttle skip — emit one log line per skipped project, then continue.
|
|
979
|
+
// Sub-item W-mq03l6zh0006f0a1-b will replace the global isAdoThrottled() probe with
|
|
980
|
+
// a per-org `isOrgBaseThrottled(orgBase)` check so a 429 on one org no longer pauses
|
|
981
|
+
// polling for healthy orgs.
|
|
982
|
+
if (isAdoThrottled()) {
|
|
983
|
+
log('info', `[ado] PR poll skipped for ${project.name || project.repoName || 'unknown project'} — org ${orgBase} throttled`);
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
|
|
978
987
|
// Parallelize PR polling within each project (max 5 concurrent to avoid rate limits)
|
|
979
988
|
const CONCURRENCY = 5;
|
|
980
989
|
for (let i = 0; i < activePrs.length; i += CONCURRENCY) {
|
package/engine/github.js
CHANGED
|
@@ -295,7 +295,10 @@ function resetSlugBackoff(slug) {
|
|
|
295
295
|
// ─── GitHub Rate-Limit Throttle ────────────────────────────────────────────
|
|
296
296
|
// Tracks rate-limiting from GitHub API (gh CLI exits non-zero with rate-limit messages).
|
|
297
297
|
// GitHub rate limits reset hourly, so cap at 60 min.
|
|
298
|
-
|
|
298
|
+
// jitterRatio: 0.2 — apply ±20% random jitter to backoff to avoid thundering herd
|
|
299
|
+
// when many concurrent gh calls race the same 1-hr reset window. See sub-item
|
|
300
|
+
// W-mq03l6zh0006f0a1-a for the createThrottleTracker jitter math.
|
|
301
|
+
const _ghThrottle = createThrottleTracker({ label: 'gh', baseBackoffMs: 60000, maxBackoffMs: 60 * 60000, jitterRatio: 0.2 });
|
|
299
302
|
|
|
300
303
|
/** Returns true if GitHub is rate-limited and retryAfter hasn't elapsed. */
|
|
301
304
|
const isGhThrottled = () => _ghThrottle.isThrottled();
|