@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.
Files changed (41) hide show
  1. package/bin/install-internal-minions.js +136 -6
  2. package/bin/minions.js +27 -13
  3. package/dashboard/js/agent-identity.js +121 -0
  4. package/dashboard/js/modal.js +4 -0
  5. package/dashboard/js/refresh.js +38 -3
  6. package/dashboard/js/render-agents.js +14 -2
  7. package/dashboard/js/render-dispatch.js +2 -2
  8. package/dashboard/js/render-other.js +147 -13
  9. package/dashboard/js/render-prd.js +6 -5
  10. package/dashboard/js/render-prs.js +1 -1
  11. package/dashboard/js/render-work-items.js +436 -21
  12. package/dashboard/js/utils.js +40 -0
  13. package/dashboard/pages/tools.html +1 -0
  14. package/dashboard/pages/work.html +13 -0
  15. package/dashboard/styles.css +73 -0
  16. package/dashboard-build.js +2 -2
  17. package/dashboard.js +93 -7
  18. package/docs/README.md +1 -0
  19. package/docs/copilot-cli-schema.md +1 -0
  20. package/docs/engine-restart.md +4 -2
  21. package/docs/internal-install.md +44 -5
  22. package/docs/named-agents.md +48 -0
  23. package/docs/runtime-adapters.md +39 -0
  24. package/docs/temporary-agents.md +172 -0
  25. package/engine/ado/comment.js +261 -4
  26. package/engine/agents/llm.js +26 -0
  27. package/engine/agents/playbook.js +2 -1
  28. package/engine/core/queries.js +27 -2
  29. package/engine/core/shared.js +148 -1
  30. package/engine/operations/cli.js +102 -1
  31. package/engine/orchestration/lifecycle.js +7 -0
  32. package/engine/orchestration/routing.js +4 -1
  33. package/engine/providers/gh-comment.js +159 -0
  34. package/engine/recovery/stop-stack.js +16 -2
  35. package/engine/runtimes/claude.js +3 -0
  36. package/engine/runtimes/codex.js +4 -0
  37. package/engine/runtimes/copilot.js +23 -2
  38. package/engine.js +2 -2
  39. package/package.json +1 -1
  40. package/playbooks/fix.md +23 -2
  41. 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.
@@ -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 values we use. `active` (1) is the default for
42
- // actionable threads; `closed` (4) is used for non-actionable threads posted
43
- // pre-resolved so they don't clutter the PR with noise the author must triage.
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
  };
@@ -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) || { name: agentId, role: 'Temporary Agent', expertise: [] };
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
 
@@ -501,9 +501,15 @@ function getMetrics() {
501
501
  const runtimeName = shared.resolveAgentCli(configuredAgents[agentId], engineConfig);
502
502
  m.runtime = runtimeName;
503
503
  try {
504
- m.costTracking = resolveRuntime(runtimeName).capabilities?.costTracking === true;
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
- roster.push({ id: d.agent, name: d.agentName || d.agent, role: d.agentRole || 'Temp Agent', emoji: '\u{1F4A8}', expertise: [], _temp: true });
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,