@yemi33/minions 0.1.2454 → 0.1.2455
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/bin/install-internal-minions.js +136 -6
- package/bin/minions.js +27 -13
- package/dashboard/js/agent-identity.js +121 -0
- package/dashboard/js/modal.js +4 -0
- package/dashboard/js/refresh.js +38 -3
- package/dashboard/js/render-agents.js +14 -2
- package/dashboard/js/render-dispatch.js +2 -2
- package/dashboard/js/render-other.js +147 -13
- package/dashboard/js/render-prd.js +6 -5
- package/dashboard/js/render-prs.js +1 -1
- package/dashboard/js/render-work-items.js +436 -21
- package/dashboard/js/utils.js +40 -0
- package/dashboard/pages/tools.html +1 -0
- package/dashboard/pages/work.html +13 -0
- package/dashboard/styles.css +73 -0
- package/dashboard-build.js +2 -2
- package/dashboard.js +93 -7
- package/docs/README.md +1 -0
- package/docs/copilot-cli-schema.md +1 -0
- package/docs/engine-restart.md +4 -2
- package/docs/internal-install.md +44 -5
- package/docs/named-agents.md +48 -0
- package/docs/runtime-adapters.md +39 -0
- package/docs/temporary-agents.md +172 -0
- package/engine/ado/comment.js +261 -4
- package/engine/agents/llm.js +26 -0
- package/engine/agents/playbook.js +2 -1
- package/engine/core/queries.js +27 -2
- package/engine/core/shared.js +148 -1
- package/engine/operations/cli.js +102 -1
- package/engine/orchestration/lifecycle.js +7 -0
- package/engine/orchestration/routing.js +4 -1
- package/engine/providers/gh-comment.js +159 -0
- package/engine/recovery/stop-stack.js +16 -2
- package/engine/runtimes/claude.js +3 -0
- package/engine/runtimes/codex.js +4 -0
- package/engine/runtimes/copilot.js +23 -2
- package/engine.js +2 -2
- package/package.json +1 -1
- package/playbooks/fix.md +23 -2
- package/playbooks/shared-rules.md +24 -2
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Temporary Agents — ephemeral `temp-<uid>` fallback dispatch
|
|
2
|
+
|
|
3
|
+
Minions normally dispatches work to a fixed roster of **named agents** (Ripley, Dallas, …;
|
|
4
|
+
see [`named-agents.md`](named-agents.md)). When every eligible named agent is busy and the
|
|
5
|
+
operator has opted in, the engine can spawn a **temporary agent** — a one-shot, unnamed
|
|
6
|
+
`temp-<uid>` identity that exists only to absorb overflow work so the queue does not stall.
|
|
7
|
+
|
|
8
|
+
This document describes the complete temp-agent behavior, verified against the current
|
|
9
|
+
implementation. Temp agents are deliberately minimal: they have **no charter, no persona,
|
|
10
|
+
no personal memory, and no persistent config record**. They are a scheduling escape valve,
|
|
11
|
+
not a sixth teammate.
|
|
12
|
+
|
|
13
|
+
> Orientation only — the load-bearing contracts live in code. Cross-references:
|
|
14
|
+
> [`named-agents.md`](named-agents.md), [`team-memory.md`](team-memory.md),
|
|
15
|
+
> [`workspace-manifests.md`](workspace-manifests.md), [`runtime-adapters.md`](runtime-adapters.md),
|
|
16
|
+
> and [`../CLAUDE.md`](../CLAUDE.md).
|
|
17
|
+
|
|
18
|
+
## 1. When and why a temp agent is created
|
|
19
|
+
|
|
20
|
+
Temp agents are an **opt-in, last-resort** step of agent resolution in
|
|
21
|
+
`resolveAgent(workType, config, opts)` (`engine/orchestration/routing.js`). Resolution tries,
|
|
22
|
+
in order: agent hints → routing `preferred` → routing `fallback` → any idle configured agent
|
|
23
|
+
(lowest error rate first). Only if **all** of those miss does it consider a temp agent, and
|
|
24
|
+
only when every one of these gates passes:
|
|
25
|
+
|
|
26
|
+
1. **Config gate — `engine.allowTempAgents`.** Defaults to **`false`**
|
|
27
|
+
(`ENGINE_DEFAULTS.allowTempAgents`, `engine/core/shared.js`). With it off, `resolveAgent`
|
|
28
|
+
returns `null` and the work item simply stays `pending` until a named agent frees up — no
|
|
29
|
+
temp is ever created (`engine/orchestration/routing.js`, the `if (config.engine?.allowTempAgents)`
|
|
30
|
+
branch). It is exposed as a Settings toggle ("Allow Temp Agents", `set-allowTempAgents`) in
|
|
31
|
+
`dashboard/js/settings.js` and surfaced on `GET /api/*` status via `dashboard.js`.
|
|
32
|
+
2. **Concurrency gate — per-tick temp budget.** The engine calls `setTempBudget(maxConcurrent - activeCount)`
|
|
33
|
+
once per tick (`engine.js`), so temps count against `engine.maxConcurrent` exactly like
|
|
34
|
+
named agents. When the budget is exhausted, `resolveAgent` logs
|
|
35
|
+
`Temp agent refused … per-tick budget exhausted` and returns `null`. This guard stops a
|
|
36
|
+
mass-discovery pass (e.g. a PR-poll sweep over 20 failing PRs) from registering one temp per
|
|
37
|
+
pending item and spawning far more processes than `maxConcurrent` allows (closes #1209).
|
|
38
|
+
|
|
39
|
+
Because a `temp-<uid>` id is by construction never equal to a named author agent, a temp is
|
|
40
|
+
always a valid **non-author** reviewer under the self-review ban, and a valid reassignment
|
|
41
|
+
target under `excludeAgent`. Temp creation is also the documented fallback for the
|
|
42
|
+
**decompose** flow: when `engine.autoDecompose` splits an `implement:large` item and all
|
|
43
|
+
permanent agents are busy, the engine can spawn a `temp-<uid>` to run a sub-task
|
|
44
|
+
(see [`../CLAUDE.md`](../CLAUDE.md) → "Decomposition").
|
|
45
|
+
|
|
46
|
+
## 2. Naming and runtime/state representation
|
|
47
|
+
|
|
48
|
+
- **Id:** `temp-${shared.uid()}` — e.g. `temp-mscilxge004b1de2`. The `temp-` prefix is a
|
|
49
|
+
reserved sentinel checked throughout the engine; a configured agent id may not collide with
|
|
50
|
+
it (`docs/specs/agent-configurability.md`).
|
|
51
|
+
- **Display name / role:** `{ name: "Temp-<4chars>", role: "Temporary Agent" }`, derived from
|
|
52
|
+
characters 5–8 of the id (`engine/orchestration/routing.js`).
|
|
53
|
+
- **In-memory only:** the record lives in a module-level `Map` `tempAgents` in
|
|
54
|
+
`engine/orchestration/routing.js` (`tempAgentId → { name, role, createdAt }`). It is **not**
|
|
55
|
+
written to `config.json`, and **not** persisted to SQLite. The authoritative agent identity
|
|
56
|
+
for the work travels on the work item / dispatch record (`item.agent`, `item.agentName`,
|
|
57
|
+
`item.agentRole`), which persist normally.
|
|
58
|
+
- **Dashboard/API:** an *active* temp agent is added to the agent roster snapshot by
|
|
59
|
+
`engine/core/queries.js#getAgents` with `emoji: 💨`, `expertise: []`, and `_temp: true`, so
|
|
60
|
+
it shows up as a tile while it runs. Temp agents are deliberately **excluded** from per-agent
|
|
61
|
+
aggregate metrics — PR counts and runtime-by-agent skip `temp-*` ids
|
|
62
|
+
(`engine/core/queries.js`).
|
|
63
|
+
|
|
64
|
+
## 3. Charter / persona — not inherited
|
|
65
|
+
|
|
66
|
+
A temp agent does **not** inherit, copy, or reference the charter or persona of any permanent
|
|
67
|
+
agent. It has no `charter.md`: `buildSystemPrompt` reads
|
|
68
|
+
`agents/<id>/charter.md`, which returns empty for a temp id, and falls back to a bare identity
|
|
69
|
+
of `{ name, role: "Temporary Agent", expertise: [] }` (`engine/agents/playbook.js`). The system
|
|
70
|
+
prompt therefore carries only the generic role label and the shared team rules — no expertise
|
|
71
|
+
tags, no voice, no boundaries section.
|
|
72
|
+
|
|
73
|
+
## 4. Memory, notes, and pinned context — retrieved, not inherited
|
|
74
|
+
|
|
75
|
+
Temp agents get the **shared, non-personal** context every dispatch gets, but accrue and read
|
|
76
|
+
**no personal memory**. Exact behavior (`engine/agents/playbook.js`):
|
|
77
|
+
|
|
78
|
+
| Context source | Temp agent behavior |
|
|
79
|
+
|----------------|---------------------|
|
|
80
|
+
| `pinned.md` (operator pinned context) | **Injected** — unconditional, same as any agent. |
|
|
81
|
+
| Task-selected **Relevant Memory** pack (`memoryRetrieval` on) | **Injected** — the retrieval query is keyed on the **work item / project**, not the agent persona, so temps get the same project-scoped pack. |
|
|
82
|
+
| `notes.md` (team notes) | **Injected** (when the Relevant Memory pack did not already replace legacy injection). Not gated on agent id. |
|
|
83
|
+
| Applicable **review-learning** lessons | **Injected** — project/file-scoped recall, agent-independent. |
|
|
84
|
+
| Per-agent memory `knowledge/agents/<id>.md` | **NOT injected** — the injection is explicitly skipped for ids starting with `temp-` (`engine/agents/playbook.js`), and no such file exists for a temp anyway. |
|
|
85
|
+
|
|
86
|
+
It does **not** inherit a *specific* permanent agent's `knowledge/agents/<agentId>.md`
|
|
87
|
+
notebook — there is no "borrow Ripley's memory" path. A temp reads shared/project memory only.
|
|
88
|
+
|
|
89
|
+
**Write side (consolidation).** A temp agent's findings never become durable personal memory.
|
|
90
|
+
The consolidation pipeline (`engine/memory/consolidation.js`) skips `temp-*` authors in
|
|
91
|
+
`appendToAgentMemory`, `reconcileAndAppendToAgentMemory`, and `maybeSummarizeAgentMemory`, and
|
|
92
|
+
memory routing treats `temp-*` as an ineligible route. Team-wide consolidation into `notes.md`
|
|
93
|
+
and the categorized `knowledge/` KB still happens from any inbox note the temp writes (those
|
|
94
|
+
are team-scoped, not per-agent), but nothing is filed under a `temp-*` identity.
|
|
95
|
+
|
|
96
|
+
## 5. Runtime, CLI, model, tools, and workspace manifest
|
|
97
|
+
|
|
98
|
+
Because a temp agent has **no `config.agents.<id>` entry**, every per-agent override resolves to
|
|
99
|
+
`null` and falls through to the **engine fleet defaults** (`engine.js#spawnAgent`, with
|
|
100
|
+
`agentConfig = config.agents?.[agentId] || null`):
|
|
101
|
+
|
|
102
|
+
- **CLI runtime:** `resolveAgentCli(null, engine)` → `engine.defaultCli` → `'copilot'`.
|
|
103
|
+
- **Model:** `resolveAgentModel(null, engine)` → `engine.defaultModel` → runtime default.
|
|
104
|
+
- **Budget:** `resolveAgentMaxBudget(null, engine)` → `engine.maxBudgetUsd`.
|
|
105
|
+
- **Bare mode:** `resolveAgentBareMode(null, engine)` → `engine.claudeBareMode` → `false`.
|
|
106
|
+
- **Workspace manifest:** `resolveAgentManifest(null)` returns the **permissive default** — no
|
|
107
|
+
`allowed_repos` gate, no `allowed_tools` narrowing (`engine/core/shared.js`,
|
|
108
|
+
[`workspace-manifests.md`](workspace-manifests.md)). A temp therefore inherits the fleet
|
|
109
|
+
`--allowedTools` set unmodified and is never blocked by the repo gate.
|
|
110
|
+
- **Session resume:** temp ids are excluded from session persistence in every runtime adapter
|
|
111
|
+
(`getResumeSessionId` / session save short-circuit on `temp-*` in
|
|
112
|
+
`engine/runtimes/{claude,copilot,codex}.js`), so a temp never resumes a prior session.
|
|
113
|
+
|
|
114
|
+
Spawn otherwise follows the normal path — worktree allocation, prompt build, and
|
|
115
|
+
`engine/agents/spawn-agent.js` — identical to a named agent.
|
|
116
|
+
|
|
117
|
+
## 6. Routing, retries, and permanent-agent availability
|
|
118
|
+
|
|
119
|
+
- **Overflow only.** A temp is only ever chosen after hints, preferred, fallback, and any idle
|
|
120
|
+
configured agent all miss. The moment a named agent is idle, routing prefers it — temps are
|
|
121
|
+
never chosen ahead of an available permanent.
|
|
122
|
+
- **API validation.** `POST /api/work-items` accepts an explicit `temp-*` agent id **only when
|
|
123
|
+
`allowTempAgents` is true**; otherwise it is rejected as an unknown agent
|
|
124
|
+
(`engine/planning/work-item-validation.js`; the `tempAllowed` check).
|
|
125
|
+
- **Retry / reassignment.** The per-agent retry-reassignment machinery
|
|
126
|
+
([`named-agents.md` §3](named-agents.md)) treats a temp id like any other agent for
|
|
127
|
+
`excludeAgent` purposes. If `allowTempAgents` is off (or the budget is exhausted) and no
|
|
128
|
+
alternate named agent exists, the engine writes a deduped inbox note advising the operator to
|
|
129
|
+
enable `allowTempAgents` or add another routing target, then re-dispatches to the same agent to
|
|
130
|
+
avoid deadlock (`engine.js`).
|
|
131
|
+
- **Unknown-agent guard.** A pending dispatch whose `item.agent` is a `temp-*` id is **not**
|
|
132
|
+
treated as an "unknown configured agent" and is not re-routed on that basis (`engine.js`), so a
|
|
133
|
+
temp assignment survives to spawn even though it is absent from `config.agents`.
|
|
134
|
+
|
|
135
|
+
## 7. Lifecycle and cleanup
|
|
136
|
+
|
|
137
|
+
- **Ephemeral by design.** `cleanupTempAgent(agentId)` (`engine.js`) runs on **every**
|
|
138
|
+
dispatch-completion path (success, failure, timeout, orphan recovery, …). It deletes the id
|
|
139
|
+
from the in-memory `tempAgents` map and removes the `agents/<temp-id>/` directory (live-output
|
|
140
|
+
log etc.), wrapped in `shared._retryFsOp` so Windows `EBUSY`/`EPERM` from AV/file-indexer locks
|
|
141
|
+
are retried (regression test: `test/unit/cleanup-temp-agent-retry.test.js`).
|
|
142
|
+
- **Orphan sweep.** As a backstop, the periodic cleanup pass
|
|
143
|
+
(`engine/orchestration/cleanup.js`) reaps `agents/temp-*` directories that are no longer
|
|
144
|
+
referenced by any dispatch record, using a **1-hour mtime gate** so a still-spawning temp is
|
|
145
|
+
never reaped mid-launch.
|
|
146
|
+
- **Restart behavior.** The `tempAgents` map is in-memory, so an engine restart discards the
|
|
147
|
+
`{ name, role }` record. The persisted work-item/dispatch fields (`item.agent`,
|
|
148
|
+
`item.agentName`, `item.agentRole`) survive, and if a pending temp dispatch is resumed after
|
|
149
|
+
restart, `buildSystemPrompt` degrades gracefully to the default `Temporary Agent` identity
|
|
150
|
+
(`engine/agents/playbook.js`). No temp identity is re-registered or "revived" — it simply runs
|
|
151
|
+
out its one dispatch and is cleaned up.
|
|
152
|
+
- **Retained artifacts.** Only team-scoped artifacts a temp produced during its run persist:
|
|
153
|
+
inbox notes it wrote (subject to normal team consolidation), tracked PRs it opened (attributed
|
|
154
|
+
to the `temp-*` id but excluded from per-agent PR metrics), and the work item's own history.
|
|
155
|
+
No personal memory, charter, or config record is retained.
|
|
156
|
+
|
|
157
|
+
## 8. Limitations and isolation implications
|
|
158
|
+
|
|
159
|
+
- **Off by default; overflow only.** With `allowTempAgents: false` (the default) temps never
|
|
160
|
+
exist; enabling them is an explicit operator decision (config + Settings toggle).
|
|
161
|
+
- **Fleet-default permissions.** A temp runs with the fleet-default runtime, model, budget, and
|
|
162
|
+
tool set, and a **permissive** workspace manifest (no repo gate, no tool narrowing). If your
|
|
163
|
+
isolation model depends on per-agent `workspace_manifest` restrictions, note that a temp agent
|
|
164
|
+
bypasses them by construction — it has no manifest to enforce. Constrain temps through
|
|
165
|
+
fleet-level `engine.*` defaults (e.g. `engine.maxBudgetUsd`, `engine.allowedTools`) rather than
|
|
166
|
+
per-agent config.
|
|
167
|
+
- **No accountable persona / no learning.** Temps carry no charter and accrue no personal
|
|
168
|
+
memory, so their work is not shaped by, and does not feed back into, any named agent's memory.
|
|
169
|
+
They are appropriate for burst capacity, not for work that should build durable per-agent
|
|
170
|
+
context.
|
|
171
|
+
- **Concurrency-bounded.** The per-tick temp budget ties temp creation to `engine.maxConcurrent`;
|
|
172
|
+
temps cannot exceed the global concurrency cap.
|
package/engine/ado/comment.js
CHANGED
|
@@ -31,18 +31,68 @@
|
|
|
31
31
|
* `active` (1). ADO honors the status field on thread creation, so unlike GitHub
|
|
32
32
|
* this needs no second API round-trip. Actionable comments (bugs, required
|
|
33
33
|
* changes) stay `active` so they block/notify the author.
|
|
34
|
+
*
|
|
35
|
+
* Fix summaries on the ORIGINATING thread (W-msd9os8e00qnd628):
|
|
36
|
+
* `replyToAdoThread` posts a fix-summary reply onto an existing review thread
|
|
37
|
+
* and transitions that thread to `fixed` (2) / `closed` (4), rather than opening
|
|
38
|
+
* a fresh active summary thread. It reads the thread first so retries / restarts
|
|
39
|
+
* / duplicate callbacks are idempotent (marker-keyed reply dedupe; already-
|
|
40
|
+
* resolved threads left as-is) and a reply/PATCH partial failure is surfaced and
|
|
41
|
+
* retryable.
|
|
34
42
|
*/
|
|
35
43
|
|
|
36
|
-
const { buildMinionsCommentBody } = require('../providers/comment-format');
|
|
44
|
+
const { buildMinionsCommentBody, parseMinionsMarker } = require('../providers/comment-format');
|
|
37
45
|
const { acquireAdoToken } = require('./token');
|
|
38
46
|
|
|
39
47
|
const ADO_API_VERSION = '7.1';
|
|
40
48
|
|
|
41
|
-
// ADO CommentThreadStatus enum
|
|
42
|
-
//
|
|
43
|
-
// pre-resolved so they don't clutter
|
|
49
|
+
// ADO CommentThreadStatus enum. `active` (1) is the default for actionable
|
|
50
|
+
// threads; `fixed` (2) records a finding the fix agent addressed; `closed` (4)
|
|
51
|
+
// is used for non-actionable threads posted pre-resolved so they don't clutter
|
|
52
|
+
// the PR with noise the author must triage.
|
|
53
|
+
const ADO_THREAD_STATUS_UNKNOWN = 0;
|
|
44
54
|
const ADO_THREAD_STATUS_ACTIVE = 1;
|
|
55
|
+
const ADO_THREAD_STATUS_FIXED = 2;
|
|
56
|
+
const ADO_THREAD_STATUS_WONT_FIX = 3;
|
|
45
57
|
const ADO_THREAD_STATUS_CLOSED = 4;
|
|
58
|
+
const ADO_THREAD_STATUS_BY_DESIGN = 5;
|
|
59
|
+
const ADO_THREAD_STATUS_PENDING = 6;
|
|
60
|
+
|
|
61
|
+
// Canonical mapping from a caller-friendly status name to the ADO enum value.
|
|
62
|
+
// Used by the reply-and-resolve path so a fix agent can transition the
|
|
63
|
+
// ORIGINATING thread to a resolved state (`fixed`/`closed`) rather than opening
|
|
64
|
+
// a new active summary thread. `active` is the "leave unresolved" sentinel.
|
|
65
|
+
const ADO_THREAD_STATUS_BY_NAME = Object.freeze({
|
|
66
|
+
active: ADO_THREAD_STATUS_ACTIVE,
|
|
67
|
+
fixed: ADO_THREAD_STATUS_FIXED,
|
|
68
|
+
wontfix: ADO_THREAD_STATUS_WONT_FIX,
|
|
69
|
+
'wont-fix': ADO_THREAD_STATUS_WONT_FIX,
|
|
70
|
+
closed: ADO_THREAD_STATUS_CLOSED,
|
|
71
|
+
resolved: ADO_THREAD_STATUS_FIXED,
|
|
72
|
+
bydesign: ADO_THREAD_STATUS_BY_DESIGN,
|
|
73
|
+
'by-design': ADO_THREAD_STATUS_BY_DESIGN,
|
|
74
|
+
pending: ADO_THREAD_STATUS_PENDING,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Thread statuses that mean "resolved" for idempotency purposes — a thread
|
|
78
|
+
// already in one of these needs no further status PATCH.
|
|
79
|
+
const ADO_RESOLVED_THREAD_STATUSES = new Set([
|
|
80
|
+
ADO_THREAD_STATUS_FIXED,
|
|
81
|
+
ADO_THREAD_STATUS_WONT_FIX,
|
|
82
|
+
ADO_THREAD_STATUS_CLOSED,
|
|
83
|
+
ADO_THREAD_STATUS_BY_DESIGN,
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
function resolveAdoThreadStatus(status) {
|
|
87
|
+
if (typeof status === 'number' && Number.isInteger(status)) return status;
|
|
88
|
+
const key = String(status || '').trim().toLowerCase();
|
|
89
|
+
if (key === '') return ADO_THREAD_STATUS_ACTIVE;
|
|
90
|
+
const mapped = ADO_THREAD_STATUS_BY_NAME[key];
|
|
91
|
+
if (mapped === undefined) {
|
|
92
|
+
throw new Error(`invalid thread status: ${JSON.stringify(status)} (expected one of ${Object.keys(ADO_THREAD_STATUS_BY_NAME).join(', ')})`);
|
|
93
|
+
}
|
|
94
|
+
return mapped;
|
|
95
|
+
}
|
|
46
96
|
|
|
47
97
|
// ── Validation (ADO-specific) ────────────────────────────────────────────────
|
|
48
98
|
// Field validation for the marker (agentId / kind / workItemId) is done inside
|
|
@@ -72,6 +122,12 @@ function _validatePrNumber(prNumber) {
|
|
|
72
122
|
}
|
|
73
123
|
}
|
|
74
124
|
|
|
125
|
+
function _validateThreadId(threadId) {
|
|
126
|
+
if (typeof threadId !== 'number' || !Number.isInteger(threadId) || threadId <= 0) {
|
|
127
|
+
throw new Error(`invalid threadId: ${JSON.stringify(threadId)} (expected positive integer)`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
75
131
|
/**
|
|
76
132
|
* Build the ADO threads endpoint for a PR. `orgBase` already includes the org
|
|
77
133
|
* (e.g. https://dev.azure.com/contoso). Project + repositoryId are URL-encoded.
|
|
@@ -82,6 +138,25 @@ function buildThreadsUrl({ orgBase, project, repositoryId, prNumber }) {
|
|
|
82
138
|
+ `/pullRequests/${prNumber}/threads?api-version=${ADO_API_VERSION}`;
|
|
83
139
|
}
|
|
84
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Endpoint for a single existing thread (GET current status/comments, PATCH
|
|
143
|
+
* status). `orgBase` already includes the org.
|
|
144
|
+
*/
|
|
145
|
+
function buildThreadUrl({ orgBase, project, repositoryId, prNumber, threadId }) {
|
|
146
|
+
const base = String(orgBase).replace(/\/+$/, '');
|
|
147
|
+
return `${base}/${encodeURIComponent(project)}/_apis/git/repositories/${encodeURIComponent(repositoryId)}`
|
|
148
|
+
+ `/pullRequests/${prNumber}/threads/${threadId}?api-version=${ADO_API_VERSION}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Endpoint for the comments collection of an existing thread (POST a reply).
|
|
153
|
+
*/
|
|
154
|
+
function buildThreadCommentsUrl({ orgBase, project, repositoryId, prNumber, threadId }) {
|
|
155
|
+
const base = String(orgBase).replace(/\/+$/, '');
|
|
156
|
+
return `${base}/${encodeURIComponent(project)}/_apis/git/repositories/${encodeURIComponent(repositoryId)}`
|
|
157
|
+
+ `/pullRequests/${prNumber}/threads/${threadId}/comments?api-version=${ADO_API_VERSION}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
85
160
|
/**
|
|
86
161
|
* Default token acquisition — thin wrapper over engine/ado/token.js so callers
|
|
87
162
|
* can inject a stub in tests. Returns the bearer token string or throws.
|
|
@@ -174,12 +249,193 @@ async function postAdoPrComment({
|
|
|
174
249
|
};
|
|
175
250
|
}
|
|
176
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Reply to an EXISTING review thread with a fix summary and transition that
|
|
254
|
+
* thread to a resolved status — instead of opening a new active summary thread.
|
|
255
|
+
*
|
|
256
|
+
* This is the fix-summary path for Azure DevOps (W-msd9os8e00qnd628): when a fix
|
|
257
|
+
* agent has evidence a review finding was addressed, it replies on the
|
|
258
|
+
* originating thread (identified by the structured `threadId` carried on the
|
|
259
|
+
* work item / completion report) and moves that thread to `fixed` (2) / `closed`
|
|
260
|
+
* (4). Contrast with `postAdoPrComment`, which creates a fresh thread.
|
|
261
|
+
*
|
|
262
|
+
* Idempotency (retries / engine restarts / duplicate completion callbacks /
|
|
263
|
+
* provider timeouts): the current thread is read first. A reply whose hidden
|
|
264
|
+
* marker already matches this dispatch's structured identity (agentId + kind +
|
|
265
|
+
* workItemId) is treated as already-posted and the reply POST is skipped; a
|
|
266
|
+
* thread already in a resolved status is left as-is. Resolution is keyed by the
|
|
267
|
+
* explicit `threadId` and the structured marker — never by body/title text — so
|
|
268
|
+
* sibling findings are never bulk-resolved.
|
|
269
|
+
*
|
|
270
|
+
* Partial-failure visibility: the reply POST and the status PATCH are distinct
|
|
271
|
+
* round-trips. If the reply succeeds but the status PATCH fails, we throw so the
|
|
272
|
+
* caller sees the failure and can retry; the retry re-reads the thread, finds
|
|
273
|
+
* the reply already present (skips it), and re-attempts only the PATCH. Local
|
|
274
|
+
* state is never marked resolved when the provider PATCH failed.
|
|
275
|
+
*
|
|
276
|
+
* @param {object} args
|
|
277
|
+
* @param {string} args.orgBase
|
|
278
|
+
* @param {string} args.project
|
|
279
|
+
* @param {string} args.repositoryId
|
|
280
|
+
* @param {number} args.prNumber
|
|
281
|
+
* @param {number} args.threadId existing ADO thread id (positive integer)
|
|
282
|
+
* @param {string} args.body reply body (pre-marker; builder prepends marker)
|
|
283
|
+
* @param {string} args.agentId
|
|
284
|
+
* @param {string} args.kind
|
|
285
|
+
* @param {string} [args.workItemId]
|
|
286
|
+
* @param {string} [args.model]
|
|
287
|
+
* @param {(string|number)} [args.status='fixed'] target status; 'active' leaves the thread unresolved
|
|
288
|
+
* @param {number} [args.timeoutMs=30000]
|
|
289
|
+
* @param {Function} [args.acquireToken]
|
|
290
|
+
* @param {Function} [args.fetchImpl]
|
|
291
|
+
* @returns {Promise<{threadId:number, commentId:(number|undefined), replied:boolean, alreadyReplied:boolean, resolved:boolean, alreadyResolved:boolean, statusApplied:(number|null)}>}
|
|
292
|
+
*/
|
|
293
|
+
async function replyToAdoThread({
|
|
294
|
+
orgBase,
|
|
295
|
+
project,
|
|
296
|
+
repositoryId,
|
|
297
|
+
prNumber,
|
|
298
|
+
threadId,
|
|
299
|
+
body,
|
|
300
|
+
agentId,
|
|
301
|
+
kind,
|
|
302
|
+
workItemId,
|
|
303
|
+
model,
|
|
304
|
+
status = 'fixed',
|
|
305
|
+
timeoutMs = 30000,
|
|
306
|
+
acquireToken = _defaultAcquireToken,
|
|
307
|
+
fetchImpl,
|
|
308
|
+
} = {}) {
|
|
309
|
+
_validateOrgBase(orgBase);
|
|
310
|
+
_validateProject(project);
|
|
311
|
+
_validateRepositoryId(repositoryId);
|
|
312
|
+
_validatePrNumber(prNumber);
|
|
313
|
+
_validateThreadId(threadId);
|
|
314
|
+
|
|
315
|
+
const targetStatus = resolveAdoThreadStatus(status);
|
|
316
|
+
|
|
317
|
+
// buildMinionsCommentBody validates marker fields and throws on bad input.
|
|
318
|
+
const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, model, body });
|
|
319
|
+
|
|
320
|
+
const token = await acquireToken();
|
|
321
|
+
if (!token || typeof token !== 'string') {
|
|
322
|
+
throw new Error('ADO comment: could not acquire an access token (az account get-access-token failed)');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const doFetch = fetchImpl || (typeof fetch === 'function' ? fetch : null);
|
|
326
|
+
if (!doFetch) throw new Error('ADO comment: no fetch implementation available');
|
|
327
|
+
|
|
328
|
+
const authHeaders = {
|
|
329
|
+
'Authorization': `Bearer ${token}`,
|
|
330
|
+
'Content-Type': 'application/json',
|
|
331
|
+
};
|
|
332
|
+
const threadUrl = buildThreadUrl({ orgBase, project, repositoryId, prNumber, threadId });
|
|
333
|
+
|
|
334
|
+
// ── 1. Read the current thread (status + existing comments) for idempotency ──
|
|
335
|
+
const getRes = await doFetch(threadUrl, {
|
|
336
|
+
method: 'GET',
|
|
337
|
+
headers: authHeaders,
|
|
338
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
339
|
+
});
|
|
340
|
+
if (!getRes.ok) {
|
|
341
|
+
const detail = await getRes.text().catch(() => '');
|
|
342
|
+
throw new Error(`ADO thread GET ${getRes.status} ${getRes.statusText}: ${String(detail).slice(0, 500)}`);
|
|
343
|
+
}
|
|
344
|
+
const thread = await getRes.json().catch(() => ({}));
|
|
345
|
+
const existingComments = Array.isArray(thread.comments) ? thread.comments : [];
|
|
346
|
+
const currentStatus = typeof thread.status === 'number'
|
|
347
|
+
? thread.status
|
|
348
|
+
: resolveAdoThreadStatus(thread.status || 'active');
|
|
349
|
+
|
|
350
|
+
// Idempotency: has THIS dispatch (structured marker identity) already replied?
|
|
351
|
+
const alreadyReplied = existingComments.some((c) => {
|
|
352
|
+
const marker = parseMinionsMarker(String(c?.content || ''));
|
|
353
|
+
if (!marker) return false;
|
|
354
|
+
if (marker.agentId !== agentId || marker.kind !== kind) return false;
|
|
355
|
+
// When a workItemId is supplied it is the structured finding identity — a
|
|
356
|
+
// match on it (or its absence on both sides) confirms this is our reply.
|
|
357
|
+
if (workItemId) return marker.workItemId === workItemId;
|
|
358
|
+
return marker.workItemId === undefined;
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// The first comment id anchors a reply to the originating thread.
|
|
362
|
+
const parentCommentId = existingComments.length > 0 && Number.isInteger(existingComments[0]?.id)
|
|
363
|
+
? existingComments[0].id
|
|
364
|
+
: 1;
|
|
365
|
+
|
|
366
|
+
let replied = false;
|
|
367
|
+
let commentId;
|
|
368
|
+
if (!alreadyReplied) {
|
|
369
|
+
const commentsUrl = buildThreadCommentsUrl({ orgBase, project, repositoryId, prNumber, threadId });
|
|
370
|
+
const postRes = await doFetch(commentsUrl, {
|
|
371
|
+
method: 'POST',
|
|
372
|
+
headers: authHeaders,
|
|
373
|
+
body: JSON.stringify({ parentCommentId, content: finalBody, commentType: 1 }),
|
|
374
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
375
|
+
});
|
|
376
|
+
if (!postRes.ok) {
|
|
377
|
+
const detail = await postRes.text().catch(() => '');
|
|
378
|
+
throw new Error(`ADO thread reply POST ${postRes.status} ${postRes.statusText}: ${String(detail).slice(0, 500)}`);
|
|
379
|
+
}
|
|
380
|
+
const postJson = await postRes.json().catch(() => ({}));
|
|
381
|
+
commentId = postJson && postJson.id;
|
|
382
|
+
replied = true;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ── 2. Transition the thread status (skip when unresolved requested or the
|
|
386
|
+
// thread is already in the requested/equivalent resolved state) ──
|
|
387
|
+
const wantsResolve = targetStatus !== ADO_THREAD_STATUS_ACTIVE
|
|
388
|
+
&& targetStatus !== ADO_THREAD_STATUS_UNKNOWN;
|
|
389
|
+
const alreadyResolved = wantsResolve && currentStatus === targetStatus;
|
|
390
|
+
let resolved = ADO_RESOLVED_THREAD_STATUSES.has(currentStatus);
|
|
391
|
+
let statusApplied = null;
|
|
392
|
+
|
|
393
|
+
if (wantsResolve && currentStatus !== targetStatus) {
|
|
394
|
+
// The reply above may already be committed. A PATCH failure here throws so
|
|
395
|
+
// the caller can retry; the retry skips the (now-present) reply and PATCHes
|
|
396
|
+
// again — local state is never marked resolved on a failed PATCH.
|
|
397
|
+
const patchRes = await doFetch(threadUrl, {
|
|
398
|
+
method: 'PATCH',
|
|
399
|
+
headers: authHeaders,
|
|
400
|
+
body: JSON.stringify({ status: targetStatus }),
|
|
401
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
402
|
+
});
|
|
403
|
+
if (!patchRes.ok) {
|
|
404
|
+
const detail = await patchRes.text().catch(() => '');
|
|
405
|
+
throw new Error(`ADO thread status PATCH ${patchRes.status} ${patchRes.statusText}: ${String(detail).slice(0, 500)} (reply ${replied ? 'was' : 'was not'} posted; retry is idempotent)`);
|
|
406
|
+
}
|
|
407
|
+
statusApplied = targetStatus;
|
|
408
|
+
resolved = true;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
threadId,
|
|
413
|
+
commentId,
|
|
414
|
+
replied,
|
|
415
|
+
alreadyReplied,
|
|
416
|
+
resolved,
|
|
417
|
+
alreadyResolved: !!alreadyResolved,
|
|
418
|
+
statusApplied,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
177
422
|
module.exports = {
|
|
178
423
|
postAdoPrComment,
|
|
424
|
+
replyToAdoThread,
|
|
179
425
|
buildThreadsUrl,
|
|
426
|
+
buildThreadUrl,
|
|
427
|
+
buildThreadCommentsUrl,
|
|
428
|
+
resolveAdoThreadStatus,
|
|
180
429
|
ADO_API_VERSION,
|
|
430
|
+
ADO_THREAD_STATUS_UNKNOWN,
|
|
181
431
|
ADO_THREAD_STATUS_ACTIVE,
|
|
432
|
+
ADO_THREAD_STATUS_FIXED,
|
|
433
|
+
ADO_THREAD_STATUS_WONT_FIX,
|
|
182
434
|
ADO_THREAD_STATUS_CLOSED,
|
|
435
|
+
ADO_THREAD_STATUS_BY_DESIGN,
|
|
436
|
+
ADO_THREAD_STATUS_PENDING,
|
|
437
|
+
ADO_THREAD_STATUS_BY_NAME,
|
|
438
|
+
ADO_RESOLVED_THREAD_STATUSES,
|
|
183
439
|
// Re-export the neutral builder so ADO callers have a single import surface,
|
|
184
440
|
// mirroring engine/providers/gh-comment.js.
|
|
185
441
|
buildMinionsCommentBody,
|
|
@@ -188,5 +444,6 @@ module.exports = {
|
|
|
188
444
|
_validateProject,
|
|
189
445
|
_validateRepositoryId,
|
|
190
446
|
_validatePrNumber,
|
|
447
|
+
_validateThreadId,
|
|
191
448
|
_defaultAcquireToken,
|
|
192
449
|
};
|
package/engine/agents/llm.js
CHANGED
|
@@ -55,6 +55,26 @@ function _emptyDailyDelta() {
|
|
|
55
55
|
return { costUsd: 0, inputTokens: 0, outputTokens: 0, cacheRead: 0 };
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// Merge a source billableUnits map ({unit:{total,reported,unavailable}}) into a
|
|
59
|
+
// target object's billableUnits (W-msdcgitc). Used to fold buffered CC/doc-chat
|
|
60
|
+
// deltas into the persisted metrics and to re-merge on a failed flush. Generic
|
|
61
|
+
// over unit name so no runtime-name branch is needed.
|
|
62
|
+
function _mergeBillableUnits(target, source) {
|
|
63
|
+
if (!source || typeof source !== 'object') return;
|
|
64
|
+
if (!target.billableUnits || typeof target.billableUnits !== 'object') {
|
|
65
|
+
target.billableUnits = {};
|
|
66
|
+
}
|
|
67
|
+
for (const [unit, b] of Object.entries(source)) {
|
|
68
|
+
if (!b || typeof b !== 'object') continue;
|
|
69
|
+
const bucket = target.billableUnits[unit]
|
|
70
|
+
|| { total: 0, reported: 0, unavailable: 0 };
|
|
71
|
+
bucket.total += b.total || 0;
|
|
72
|
+
bucket.reported += b.reported || 0;
|
|
73
|
+
bucket.unavailable += b.unavailable || 0;
|
|
74
|
+
target.billableUnits[unit] = bucket;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
58
78
|
function _ensureFlushTimer() {
|
|
59
79
|
if (_flushTimer) return;
|
|
60
80
|
const interval = shared.ENGINE_DEFAULTS.metricsFlushIntervalMs || 10000;
|
|
@@ -74,6 +94,7 @@ function trackEngineUsage(category, usage) {
|
|
|
74
94
|
cat.outputTokens += usage.outputTokens || 0;
|
|
75
95
|
cat.cacheRead += usage.cacheRead || 0;
|
|
76
96
|
cat.cacheCreation += usage.cacheCreation || 0;
|
|
97
|
+
shared.accumulateBillableUnits(cat, usage.billable);
|
|
77
98
|
if (usage.durationMs) {
|
|
78
99
|
cat.totalDurationMs += usage.durationMs;
|
|
79
100
|
cat.timedCalls += 1;
|
|
@@ -86,6 +107,7 @@ function trackEngineUsage(category, usage) {
|
|
|
86
107
|
daily.inputTokens += usage.inputTokens || 0;
|
|
87
108
|
daily.outputTokens += usage.outputTokens || 0;
|
|
88
109
|
daily.cacheRead += usage.cacheRead || 0;
|
|
110
|
+
shared.accumulateBillableUnits(daily, usage.billable);
|
|
89
111
|
|
|
90
112
|
_ensureFlushTimer();
|
|
91
113
|
}
|
|
@@ -135,6 +157,7 @@ function flushMetricsBuffer() {
|
|
|
135
157
|
cat.errorsByCode[code] = (cat.errorsByCode[code] || 0) + count;
|
|
136
158
|
}
|
|
137
159
|
}
|
|
160
|
+
_mergeBillableUnits(cat, delta.billableUnits);
|
|
138
161
|
}
|
|
139
162
|
if (!metrics._daily) metrics._daily = {};
|
|
140
163
|
for (const [day, delta] of Object.entries(pending.daily)) {
|
|
@@ -146,6 +169,7 @@ function flushMetricsBuffer() {
|
|
|
146
169
|
d.inputTokens += delta.inputTokens;
|
|
147
170
|
d.outputTokens += delta.outputTokens;
|
|
148
171
|
d.cacheRead += delta.cacheRead;
|
|
172
|
+
_mergeBillableUnits(d, delta.billableUnits);
|
|
149
173
|
}
|
|
150
174
|
return metrics;
|
|
151
175
|
});
|
|
@@ -164,12 +188,14 @@ function flushMetricsBuffer() {
|
|
|
164
188
|
c.errorsByCode[code] = (c.errorsByCode[code] || 0) + count;
|
|
165
189
|
}
|
|
166
190
|
}
|
|
191
|
+
_mergeBillableUnits(c, delta.billableUnits);
|
|
167
192
|
}
|
|
168
193
|
for (const [day, delta] of Object.entries(pending.daily)) {
|
|
169
194
|
if (!_pendingMetrics.daily[day]) _pendingMetrics.daily[day] = _emptyDailyDelta();
|
|
170
195
|
const d = _pendingMetrics.daily[day];
|
|
171
196
|
d.costUsd += delta.costUsd; d.inputTokens += delta.inputTokens;
|
|
172
197
|
d.outputTokens += delta.outputTokens; d.cacheRead += delta.cacheRead;
|
|
198
|
+
_mergeBillableUnits(d, delta.billableUnits);
|
|
173
199
|
}
|
|
174
200
|
console.error('metrics flush:', e.message);
|
|
175
201
|
}
|
|
@@ -1373,7 +1373,8 @@ function _checkPlaybookSections(content, context = '') {
|
|
|
1373
1373
|
|
|
1374
1374
|
// Lean system prompt: agent identity + rules only (~2-4KB, never grows)
|
|
1375
1375
|
function buildSystemPrompt(agentId, config, project) {
|
|
1376
|
-
const agent = config.agents[agentId] || tempAgents.get(agentId)
|
|
1376
|
+
const agent = config.agents[agentId] || tempAgents.get(agentId)
|
|
1377
|
+
|| { name: shared.tempAgentDisplayName(agentId), role: 'Temporary Agent', expertise: [] };
|
|
1377
1378
|
const charter = safeRead(path.join(AGENTS_DIR, agentId, 'charter.md')); // returns '' for temp agents (no charter file)
|
|
1378
1379
|
project = project || getProjects(config)[0] || {};
|
|
1379
1380
|
|
package/engine/core/queries.js
CHANGED
|
@@ -501,9 +501,15 @@ function getMetrics() {
|
|
|
501
501
|
const runtimeName = shared.resolveAgentCli(configuredAgents[agentId], engineConfig);
|
|
502
502
|
m.runtime = runtimeName;
|
|
503
503
|
try {
|
|
504
|
-
|
|
504
|
+
const caps = resolveRuntime(runtimeName).capabilities || {};
|
|
505
|
+
m.costTracking = caps.costTracking === true;
|
|
506
|
+
// Typed native billable unit for the display layer (W-msdcgitc). Copilot
|
|
507
|
+
// reports 'premiumRequests'; adapters without one default to 'usd'. The
|
|
508
|
+
// dashboard reads THIS, never the runtime name, to decide labels/units.
|
|
509
|
+
m.billableUnit = typeof caps.billableUnit === 'string' ? caps.billableUnit : 'usd';
|
|
505
510
|
} catch {
|
|
506
511
|
m.costTracking = false;
|
|
512
|
+
m.billableUnit = 'usd';
|
|
507
513
|
}
|
|
508
514
|
}
|
|
509
515
|
|
|
@@ -895,7 +901,21 @@ function getAgents(config) {
|
|
|
895
901
|
const seen = new Set(roster.map(a => a.id));
|
|
896
902
|
for (const d of (dispatch.active || [])) {
|
|
897
903
|
if (d.agent && d.agent.startsWith('temp-') && !seen.has(d.agent)) {
|
|
898
|
-
|
|
904
|
+
// Additive friendly display identity; the canonical `id` (temp-<uid>)
|
|
905
|
+
// stays the machine identity for routing/SQL/paths/memory. `name` falls
|
|
906
|
+
// back to the deterministic call sign when the dispatch record predates
|
|
907
|
+
// friendly naming (e.g. after a restart with no in-memory tempAgents).
|
|
908
|
+
const identity = shared.resolveAgentIdentity(d.agent, config);
|
|
909
|
+
roster.push({
|
|
910
|
+
id: d.agent,
|
|
911
|
+
name: d.agentName || identity.displayName || d.agent,
|
|
912
|
+
role: d.agentRole || 'Temporary Agent',
|
|
913
|
+
emoji: '\u{1F4A8}',
|
|
914
|
+
expertise: [],
|
|
915
|
+
_temp: true,
|
|
916
|
+
isTemp: true,
|
|
917
|
+
callSign: identity.callSign,
|
|
918
|
+
});
|
|
899
919
|
seen.add(d.agent);
|
|
900
920
|
}
|
|
901
921
|
}
|
|
@@ -949,8 +969,13 @@ function getAgents(config) {
|
|
|
949
969
|
|
|
950
970
|
const chartered = fs.existsSync(path.join(AGENTS_DIR, a.id, 'charter.md'));
|
|
951
971
|
if (lastAction.length > 120) lastAction = lastAction.slice(0, 120) + '...';
|
|
972
|
+
// Structured display identity (canonical id stays authoritative; friendly
|
|
973
|
+
// name + temp flag are additive). Clients render a "Temp" badge from `isTemp`.
|
|
974
|
+
const isTemp = a.isTemp === true || shared.isTempAgentId(a.id);
|
|
952
975
|
return {
|
|
953
976
|
...a,
|
|
977
|
+
isTemp,
|
|
978
|
+
callSign: isTemp ? (a.callSign || shared.tempAgentCallSign(a.id)) : null,
|
|
954
979
|
runtime,
|
|
955
980
|
model,
|
|
956
981
|
displayModel: modelDisplay.model,
|