@aivi/tracker-github 0.0.0-stage → 0.0.0
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/dist/cli.d.ts +10 -0
- package/dist/cli.js +34 -0
- package/dist/client.d.ts +247 -0
- package/dist/client.js +528 -0
- package/dist/config.d.ts +60 -0
- package/dist/config.js +99 -0
- package/dist/desk.d.ts +13 -0
- package/dist/desk.js +67 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +10 -0
- package/dist/links.d.ts +56 -0
- package/dist/links.js +94 -0
- package/dist/module.d.ts +36 -0
- package/dist/module.js +817 -0
- package/dist/probe.d.ts +104 -0
- package/dist/probe.js +312 -0
- package/dist/projects.d.ts +41 -0
- package/dist/projects.js +117 -0
- package/dist/runprogress.d.ts +62 -0
- package/dist/runprogress.js +122 -0
- package/dist/setup-project.d.ts +125 -0
- package/dist/setup-project.js +638 -0
- package/dist/setup.d.ts +3 -0
- package/dist/setup.js +262 -0
- package/dist/tracker.d.ts +174 -0
- package/dist/tracker.js +505 -0
- package/dist/webhook.d.ts +102 -0
- package/dist/webhook.js +52 -0
- package/dist/work.d.ts +60 -0
- package/dist/work.js +83 -0
- package/docs/github-tracker.md +122 -0
- package/package.json +59 -4
- package/README.md +0 -3
package/dist/module.js
ADDED
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GitHub tracker module: the platform's listener, adapter and
|
|
3
|
+
* **tracker**. Everything GitHub-shaped — the webhook route and its seal,
|
|
4
|
+
* the GraphQL board, the status comment and its hidden state — lives behind
|
|
5
|
+
* `./tracker.ts`. The host's **orchestrator** owns a run: its durable
|
|
6
|
+
* record, its OpenCode session, the worker tools and the rule that only a
|
|
7
|
+
* tool call ends it. We answer for the **stages** (`@aivi/plugin`, work):
|
|
8
|
+
* `initWork` opens the ticket's status comment — GitHub gives an app no
|
|
9
|
+
* working session (apps cannot be assignees, proven 403), so the comment
|
|
10
|
+
* `startSession` creates **is** the conversation (D6) — and we answer with
|
|
11
|
+
* the ticket's **summary** and a generated branch (D8); `ready` joins the
|
|
12
|
+
* OpenCode session to that pair; `question` and `plan` render as they
|
|
13
|
+
* arrive; `endWork` says the closing words in our order — the final comment
|
|
14
|
+
* first, then the frozen status comment, then the cleared `Agent` field —
|
|
15
|
+
* and only after we speak does the orchestrator move the ticket and return
|
|
16
|
+
* the lease. Our failures stay ours: the closing retries on the next wake
|
|
17
|
+
* and at boot, decided against GitHub's real state, never a flag in
|
|
18
|
+
* somebody else's database, and a person hears of a failed closing the
|
|
19
|
+
* moment it fails — the human label rides the ticket, help is on the way. A
|
|
20
|
+
* person's message into a worker's run never passes through the orchestrator
|
|
21
|
+
* either: we post it straight into the OpenCode session — an answer when a
|
|
22
|
+
* form is open, a steer otherwise. A hand-set `Agent` value gets one fixed
|
|
23
|
+
* answer: work reaches us through the board, not through the field. What
|
|
24
|
+
* stays ours besides all that is the assistant — the conversation a person
|
|
25
|
+
* brings us directly, which still rides the channel machinery until the
|
|
26
|
+
* channels reform.
|
|
27
|
+
*/
|
|
28
|
+
import { errorMessage, laneOf } from '@aivi/core';
|
|
29
|
+
import { ChannelEngine, ConversationStore, createTurnRunner, isTerminal, stopTurn as stopRunningTurn, } from '@aivi/host';
|
|
30
|
+
import { githubClient, isGoneError } from './client.js';
|
|
31
|
+
import { assistantAgent, MODULE_ID } from './config.js';
|
|
32
|
+
import { createGithubDesk } from './desk.js';
|
|
33
|
+
import { RunLinks } from './links.js';
|
|
34
|
+
import { githubRepoCollisions, projectForIssue, projectGithub } from './projects.js';
|
|
35
|
+
import { RunProgress } from './runprogress.js';
|
|
36
|
+
import { GITHUB_FEED, GithubPlatform, registerWebhookRoute } from './tracker.js';
|
|
37
|
+
import { githubBoard, issueDossier } from './work.js';
|
|
38
|
+
/**
|
|
39
|
+
* The platform: a ticket is an assistant conversation, a status comment is a
|
|
40
|
+
* run conversation — `github:owner/repo#47` and `github:owner/repo#47:c123`.
|
|
41
|
+
* Assistant turns have effects beyond their reply, so a restart mid-turn
|
|
42
|
+
* blocks instead of discarding.
|
|
43
|
+
*/
|
|
44
|
+
export const GITHUB = {
|
|
45
|
+
// The **platform** id, and so the table prefix and the session origin. It
|
|
46
|
+
// says who a conversation is on, which is GitHub, not which package
|
|
47
|
+
// speaks for it. The module's own id — `plugins.tracker-github`, the log
|
|
48
|
+
// category — is `MODULE_ID`, and the two are deliberately different.
|
|
49
|
+
id: GITHUB_FEED,
|
|
50
|
+
label: 'GitHub',
|
|
51
|
+
replyLimit: 65_000,
|
|
52
|
+
effects: 'work',
|
|
53
|
+
describeSpeaker: turn => `[GitHub follow-up from ${turn.name}]`,
|
|
54
|
+
notices: {
|
|
55
|
+
failed: 'This worker hit an error',
|
|
56
|
+
stopped: 'Stopped at your request. The OpenCode session is left as it is for inspection.',
|
|
57
|
+
notStarted: 'I could not reach my agent runtime, so nothing was started. Send another message to try again.',
|
|
58
|
+
offline: 'aivi is going offline (a restart or shutdown). This assistant turn was stopped.',
|
|
59
|
+
offlineMidReply: 'aivi is going offline while posting my answer; it may be incomplete.',
|
|
60
|
+
offlineQueued: 'aivi is going offline (a restart or shutdown). This request stays queued and starts when it is back.',
|
|
61
|
+
blocked: 'I could not finish this and cannot tell whether the agent stopped. An operator has been notified; the issue waits until they resolve it.',
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
export function openGithubStore(store) {
|
|
65
|
+
// Every conversation is bound individually; the module binding never changes.
|
|
66
|
+
return new ConversationStore(store, GITHUB, 'github');
|
|
67
|
+
}
|
|
68
|
+
export function createGithubModule(config, makeTracker, makeBoard) {
|
|
69
|
+
return {
|
|
70
|
+
id: MODULE_ID,
|
|
71
|
+
start: services => startGithub(config, services, makeTracker ??
|
|
72
|
+
(async ({ loaded, log }) => {
|
|
73
|
+
const { client, webhookSecret } = await githubClient(log.getChild(MODULE_ID));
|
|
74
|
+
const platform = new GithubPlatform(loaded, client, webhookSecret, log.getChild(MODULE_ID), config.trust);
|
|
75
|
+
// Boards first: a missing `Status` or `Agent` field is said now,
|
|
76
|
+
// with the remedy, not mid-run.
|
|
77
|
+
await platform.warm();
|
|
78
|
+
return platform;
|
|
79
|
+
}), makeBoard ?? ((s, tracker) => githubBoard(config, s, tracker.client, s.log.getChild(MODULE_ID)))),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** The branch a ticket works on (D8): GitHub names none, so the tracker
|
|
83
|
+
* generates `aivi/<number>-<slug>` — the words a worktree lane checks out,
|
|
84
|
+
* human-readable and stable across retries. */
|
|
85
|
+
export function branchFor(ticketId, title) {
|
|
86
|
+
const number = ticketId.slice(ticketId.indexOf('#') + 1);
|
|
87
|
+
const slug = title
|
|
88
|
+
.toLowerCase()
|
|
89
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
90
|
+
.replace(/^-+|-+$/g, '')
|
|
91
|
+
.slice(0, 50)
|
|
92
|
+
.replace(/-+$/, '');
|
|
93
|
+
return slug ? `aivi/${number}-${slug}` : `aivi/${number}`;
|
|
94
|
+
}
|
|
95
|
+
async function startGithub(config, services, makeTracker, makeBoard) {
|
|
96
|
+
const log = services.log.getChild(MODULE_ID);
|
|
97
|
+
const collisions = githubRepoCollisions(services.loaded);
|
|
98
|
+
if (collisions.length)
|
|
99
|
+
throw new Error(`GitHub config: ${collisions.join('; ')}`);
|
|
100
|
+
const store = openGithubStore(services.store);
|
|
101
|
+
const links = new RunLinks(services.store);
|
|
102
|
+
/** One progress follower per run: the worker's OpenCode session mirrored
|
|
103
|
+
* into the ticket's status comment (docs: the one edited line that says
|
|
104
|
+
* what the worker is doing). It starts with the pair at `ready`, pauses
|
|
105
|
+
* while a question awaits a person, resumes when the answer lands, and
|
|
106
|
+
* stops before the closing so the closing is the last word. */
|
|
107
|
+
const runProgress = new Map();
|
|
108
|
+
const interrupted = store.recover();
|
|
109
|
+
if (interrupted.length)
|
|
110
|
+
log.warn('turns.interrupted', { blocked: interrupted.length });
|
|
111
|
+
// The adapter takes the whole platform in: the app, the credential, the
|
|
112
|
+
// route, the seal.
|
|
113
|
+
const tracker = await makeTracker(services);
|
|
114
|
+
const client = tracker.client;
|
|
115
|
+
// The board the eligibility walk reads: which projects this installation
|
|
116
|
+
// speaks for, what sits in a lane, how a ticket enters one.
|
|
117
|
+
const board = makeBoard(services, tracker);
|
|
118
|
+
// The ticket desk: GitHub's answers for the `aivi_ticket_*` tools the
|
|
119
|
+
// *host* claims (packages/host/src/ticket-desk.ts) — this module builds no
|
|
120
|
+
// tools and claims nothing; the desk travels on the Tracker registered
|
|
121
|
+
// below. The seeded OpenCode config still denies every action to every
|
|
122
|
+
// agent, and an agent file that works tickets allows the ones it speaks.
|
|
123
|
+
const desk = createGithubDesk(services.loaded, config, tracker);
|
|
124
|
+
const abort = new AbortController();
|
|
125
|
+
let engine;
|
|
126
|
+
const stop = () => {
|
|
127
|
+
abort.abort();
|
|
128
|
+
engine?.stop();
|
|
129
|
+
};
|
|
130
|
+
services.signal.addEventListener('abort', stop, { once: true });
|
|
131
|
+
const teardown = async () => {
|
|
132
|
+
stop();
|
|
133
|
+
for (const follower of runProgress.values())
|
|
134
|
+
void follower.stop();
|
|
135
|
+
try {
|
|
136
|
+
await engine?.shutdown();
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
services.signal.removeEventListener('abort', stop);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
const say = (conversation, text, kind) => tracker.comment(conversation, text, kind).catch(error => log.warn('notify.failed', { error }));
|
|
143
|
+
try {
|
|
144
|
+
const home = services.loaded.path.replace(/\/[^/]*$/, '');
|
|
145
|
+
// The assistant's turn machinery. A worker is NOT here: the orchestrator
|
|
146
|
+
// creates the worker's own OpenCode session and drives it; only the
|
|
147
|
+
// conversations people bring us directly ride this engine.
|
|
148
|
+
const ask = await createTurnRunner(GITHUB, { agent: 'unbound', directory: home }, services.loaded, services.opencode, services.events, services.store, services.dispatcher, services.log);
|
|
149
|
+
// A real comment the engine can edit by id and delete when the answer
|
|
150
|
+
// lands is GitHub's honest stand-in for Linear's ephemeral activity: the
|
|
151
|
+
// placeholder comment lives while the turn runs and is gone when it ends.
|
|
152
|
+
const delivery = {
|
|
153
|
+
send: async (conversation, text) => {
|
|
154
|
+
const id = await tracker.comment(conversation, text, 'answer');
|
|
155
|
+
// The assistant's marks (D10): the rocket replaces the eyes once the
|
|
156
|
+
// answer lands. A status surface only — no run state rides it, and
|
|
157
|
+
// GitHub makes the pair idempotent per reactor.
|
|
158
|
+
const ref = tracker.ticketOf(conversation);
|
|
159
|
+
if (ref)
|
|
160
|
+
await Promise.all([
|
|
161
|
+
client.addReaction(ref, 'rocket').catch(error => log.warn('reaction.failed', { error })),
|
|
162
|
+
client.removeOwnReaction(ref, 'eyes').catch(error => log.warn('reaction.failed', { error })),
|
|
163
|
+
]);
|
|
164
|
+
return id;
|
|
165
|
+
},
|
|
166
|
+
placeholder: async (conversation, text) => tracker.comment(conversation, text, 'progress'),
|
|
167
|
+
edit: async (conversation, id, text) => {
|
|
168
|
+
const ref = tracker.ticketOf(conversation);
|
|
169
|
+
if (!ref)
|
|
170
|
+
return;
|
|
171
|
+
await client.updateComment(ref, Number(id), text);
|
|
172
|
+
},
|
|
173
|
+
delete: async (conversation, id) => {
|
|
174
|
+
const ref = tracker.ticketOf(conversation);
|
|
175
|
+
if (!ref)
|
|
176
|
+
return;
|
|
177
|
+
await client.deleteComment(ref, Number(id));
|
|
178
|
+
},
|
|
179
|
+
notice: async (conversation, text) => {
|
|
180
|
+
await tracker.comment(conversation, text, 'outcome');
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
engine = new ChannelEngine(store, { pool: config.pool }, services.dispatcher, ask, delivery, {
|
|
184
|
+
log, // the module's own child, so engine records carry the module category
|
|
185
|
+
onRelease: services.wake,
|
|
186
|
+
onFailure: services.fail,
|
|
187
|
+
progress: { mode: config.progress, events: services.events },
|
|
188
|
+
});
|
|
189
|
+
const refuse = async (conversation, why) => {
|
|
190
|
+
log.info('session.refused', { conversation, why });
|
|
191
|
+
await say(conversation, why, 'outcome');
|
|
192
|
+
};
|
|
193
|
+
/** A hand-set `Agent` value nothing can run gets one plain, fixed answer:
|
|
194
|
+
* the assistant holds conversations people bring it, it does not
|
|
195
|
+
* improvise over a job the config never claimed. The value is cleared
|
|
196
|
+
* first — the app must not sit on the ticket as a worker it cannot be,
|
|
197
|
+
* and the field blacklists the ticket from the walk. */
|
|
198
|
+
const unclaimed = async (issue, why) => {
|
|
199
|
+
log.info('session.unclaimed', { issue: issue.identifier, why });
|
|
200
|
+
await tracker.unassign(GITHUB_FEED, issue.id).catch(error => log.warn('delegate.undone', { error }));
|
|
201
|
+
await tracker
|
|
202
|
+
.notify(GITHUB_FEED, issue.id, `I looked into ${issue.identifier}, and there is nothing I can do at this time: ${why} I have cleared the \`${'Agent'}\` field — comment on the ticket if you want to talk about it.`)
|
|
203
|
+
.catch(error => log.warn('notify.failed', { error }));
|
|
204
|
+
};
|
|
205
|
+
/**
|
|
206
|
+
* The closings we owe GitHub: every ended run whose closing has not
|
|
207
|
+
* landed. A failed closing informs a person the moment it fails — the
|
|
208
|
+
* human label rides the ticket and the ticket says why — and stays owed
|
|
209
|
+
* to the next wake and the next boot; the label stands until a person
|
|
210
|
+
* removes it. Nothing here lives in the orchestrator's record: delivery
|
|
211
|
+
* is ours, so the outbox is ours too.
|
|
212
|
+
*/
|
|
213
|
+
const closings = new Map();
|
|
214
|
+
/** Runs whose failure a person has already been told about: the help is
|
|
215
|
+
* on the way once, never repeated on every failed retry. */
|
|
216
|
+
const helped = new Set();
|
|
217
|
+
/** Our platform's word for "a person, please": the human label. */
|
|
218
|
+
const markForHuman = async (conversation, ticketId) => {
|
|
219
|
+
try {
|
|
220
|
+
await tracker.apply(conversation, ticketId, { kind: 'label', label: config.humanLabel, on: true });
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
log.warn('help.label.failed', { ticket: ticketId, error });
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
/** A worker the dispatcher could not kill, or a worker a person asked
|
|
227
|
+
* to stop while OpenCode would not answer the interrupt (ruled
|
|
228
|
+
* 2026-10-02; the stop's twin 2026-10-03): the ticket carries the
|
|
229
|
+
* human label and says plainly that a worker may still be running —
|
|
230
|
+
* help is on the way, and a person goes looking. Said once: `helped`
|
|
231
|
+
* is the memory that this closing already cried it. */
|
|
232
|
+
const tellUnconfirmed = async (runId, conversation, entry) => {
|
|
233
|
+
if (entry.outcome?.kind !== 'failure' ||
|
|
234
|
+
(entry.outcome.code !== 'kill-unconfirmed' && entry.outcome.code !== 'stop-unconfirmed') ||
|
|
235
|
+
helped.has(runId))
|
|
236
|
+
return;
|
|
237
|
+
helped.add(runId);
|
|
238
|
+
await say(conversation, entry.outcome.code === 'stop-unconfirmed'
|
|
239
|
+
? 'A person asked me to stop this worker, but my agent runtime would not answer the interrupt — it may still be running. I have marked this issue for a person.'
|
|
240
|
+
: 'The dispatcher could not stop my worker session, so one may still be loose. I have marked this issue for a person.', 'outcome');
|
|
241
|
+
await markForHuman(conversation, entry.ticketId);
|
|
242
|
+
};
|
|
243
|
+
/**
|
|
244
|
+
* One closing, driven against GitHub's real state: the **final comment
|
|
245
|
+
* first** — it is the word people read, and its hidden tie to the status
|
|
246
|
+
* comment is what makes a retry say it once — then the **frozen status
|
|
247
|
+
* comment**, then the **`Agent` value**, which **every ending clears**:
|
|
248
|
+
* the value means "aivi is working this ticket", and a finished run
|
|
249
|
+
* works it no more — leaving it set would blacklist the ticket from the
|
|
250
|
+
* walk (composed 2026-10-02 with the eligibility rule), while the status
|
|
251
|
+
* comment stays on the ticket as the readable trail either way. The lane
|
|
252
|
+
* move is not here: it is the orchestrator's decision and its debt,
|
|
253
|
+
* performed through `moveTo` after this returns.
|
|
254
|
+
*/
|
|
255
|
+
const driveClosing = async (runId) => {
|
|
256
|
+
const entry = closings.get(runId);
|
|
257
|
+
if (!entry)
|
|
258
|
+
return;
|
|
259
|
+
const link = entry.sessionId ? links.byOpencodeSession(entry.sessionId) : undefined;
|
|
260
|
+
if (!link) {
|
|
261
|
+
closings.delete(runId);
|
|
262
|
+
return void log.error('closing.unpaired', { run: runId, ticket: entry.ticketId });
|
|
263
|
+
}
|
|
264
|
+
const conversation = tracker.idFor(link.agentSession);
|
|
265
|
+
const success = entry.outcome?.kind === 'success';
|
|
266
|
+
const words = success ? entry.outcome.summary : (entry.outcome?.reason ?? 'The run ended without an outcome.');
|
|
267
|
+
try {
|
|
268
|
+
if (!(await tracker.resultShown(conversation)))
|
|
269
|
+
await tracker.markClosing(conversation, words, success);
|
|
270
|
+
await tracker.freezeStatus(conversation, success ? '**Done**' : '**Failed**');
|
|
271
|
+
await tellUnconfirmed(runId, conversation, entry);
|
|
272
|
+
await tracker.unassign(conversation, entry.ticketId).catch(error => log.warn('delegate.undone', { error }));
|
|
273
|
+
closings.delete(runId);
|
|
274
|
+
links.closingLanded(link.agentSession); // settled: no boot asks GitHub about this pair again
|
|
275
|
+
log.info('run.caughtup', { run: runId, ticket: entry.ticketId });
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
// A deleted ticket takes its status comment with it, and GitHub then
|
|
279
|
+
// answers every step with 404. The closing is owed to a surface that
|
|
280
|
+
// no longer exists: drop the debt, retire the pair, and say it
|
|
281
|
+
// plainly once. The person deleted the ticket; they need no help
|
|
282
|
+
// label on a grave, and a boot that re-asked GitHub about a dead
|
|
283
|
+
// pair every time would only teach the log to be unread.
|
|
284
|
+
if (isGoneError(error)) {
|
|
285
|
+
closings.delete(runId);
|
|
286
|
+
links.drop(link.agentSession);
|
|
287
|
+
log.warn('closing.dropped', { run: runId, ticket: entry.ticketId, error });
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
// The closing failed: the person hears it NOW — the operator must be
|
|
291
|
+
// informed — and the closing stays owed: the next wake and the next
|
|
292
|
+
// boot try again, and each step asks GitHub's real state first so a
|
|
293
|
+
// half-landed ceremony finishes without saying anything twice.
|
|
294
|
+
if (!helped.has(runId)) {
|
|
295
|
+
helped.add(runId);
|
|
296
|
+
log.error('closing.failed', { run: runId, ticket: entry.ticketId, error });
|
|
297
|
+
await say(conversation, `I could not finish reporting this run: ${errorMessage(error)}. The issue is marked for a person — help is on the way.`, 'outcome');
|
|
298
|
+
await markForHuman(conversation, entry.ticketId);
|
|
299
|
+
}
|
|
300
|
+
else
|
|
301
|
+
log.warn('closing.retry.failed', { run: runId, error });
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
const retryClosings = async () => {
|
|
306
|
+
for (const runId of [...closings.keys()])
|
|
307
|
+
await driveClosing(runId).catch(() => { });
|
|
308
|
+
};
|
|
309
|
+
/** The conversation a stage speaks through: the pair `initWork` opened
|
|
310
|
+
* and `ready` attached. A stage that cannot find it says so — the
|
|
311
|
+
* render is lost, the log is not, and the tracker's own retries answer. */
|
|
312
|
+
const conversationOf = (run) => {
|
|
313
|
+
const link = run.sessionId ? links.byOpencodeSession(run.sessionId) : undefined;
|
|
314
|
+
if (!link)
|
|
315
|
+
throw new Error(`run ${run.id} has no status-comment pair to speak through`);
|
|
316
|
+
return tracker.idFor(link.agentSession);
|
|
317
|
+
};
|
|
318
|
+
/**
|
|
319
|
+
* GitHub as the orchestrator knows it: one `Tracker` — the board the
|
|
320
|
+
* walk reads and the stages every run of ours walks through
|
|
321
|
+
* (docs/orchestrator.md). The lifecycle stages the orchestrator awaits
|
|
322
|
+
* (`initWork`, `endWork`) fail visibly; the renders it fires and forgets
|
|
323
|
+
* (`ready`, `question`, `plan`) throw into its log and are retried, if
|
|
324
|
+
* they matter, by us. `startWork` we answer with silence on purpose:
|
|
325
|
+
* the follower is already watching the session from `ready`, and a
|
|
326
|
+
* second word would only repeat the first.
|
|
327
|
+
*/
|
|
328
|
+
const work = {
|
|
329
|
+
id: MODULE_ID,
|
|
330
|
+
projects: board.projects,
|
|
331
|
+
tickets: board.tickets,
|
|
332
|
+
moveTo: board.moveTo,
|
|
333
|
+
desk,
|
|
334
|
+
/** Where the ticket sits right now, in the walk's own read: gone means
|
|
335
|
+
* deleted or off the board — never on a board this project does not
|
|
336
|
+
* speak, which is the repository's own routing answer. The
|
|
337
|
+
* orchestrator asks before every ending move (ruled 2026-10-02). */
|
|
338
|
+
ticketLane: async (projectId, ticketId) => {
|
|
339
|
+
const routed = projectGithub(services.loaded, projectId);
|
|
340
|
+
if (!routed)
|
|
341
|
+
return undefined;
|
|
342
|
+
const words = await board.issue(ticketId).catch((error) => {
|
|
343
|
+
if (isGoneError(error))
|
|
344
|
+
return undefined; // deleted says the same in other words
|
|
345
|
+
throw error;
|
|
346
|
+
});
|
|
347
|
+
if (!words || !routed.repos.includes(words.teamId))
|
|
348
|
+
return undefined;
|
|
349
|
+
return words.stateName || undefined; // on a mapped repo, off the board: no lane
|
|
350
|
+
},
|
|
351
|
+
/** The status comment is the whole opening (D6): GitHub makes no
|
|
352
|
+
* working session for an app, so we open the ticket's one visible
|
|
353
|
+
* word ourselves — the first line stands, the machine state rides
|
|
354
|
+
* under it (D7) — and record the pair **before** anything else can
|
|
355
|
+
* arrive. The branch is ours too (D8): GitHub names none, the walk's
|
|
356
|
+
* worktree lanes need one, so the tracker generates it. */
|
|
357
|
+
initWork: async (run) => {
|
|
358
|
+
const issue = await board.issue(run.ticketId);
|
|
359
|
+
if (!issue)
|
|
360
|
+
throw new Error('the ticket is gone from the board (deleted by a person)');
|
|
361
|
+
// A run that dies here has no conversation to speak through, and
|
|
362
|
+
// silence was seven failures (live, 2026-10-02): the ticket itself
|
|
363
|
+
// gets the plain word before the run fails. If even the comment
|
|
364
|
+
// cannot land, the log carries it — never nothing.
|
|
365
|
+
const visible = async (why) => {
|
|
366
|
+
await tracker
|
|
367
|
+
.notify(GITHUB_FEED, run.ticketId, `I could not start work on this ticket: ${why}`)
|
|
368
|
+
.catch(error => log.warn('initWork.notice.failed', { error }));
|
|
369
|
+
throw new Error(why);
|
|
370
|
+
};
|
|
371
|
+
let session;
|
|
372
|
+
try {
|
|
373
|
+
session = await tracker.startSession(GITHUB_FEED, run.ticketId);
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
return visible(`the status comment failed: ${errorMessage(error)}`);
|
|
377
|
+
}
|
|
378
|
+
if (!session)
|
|
379
|
+
return visible('GitHub made no status comment for the ticket. If aivi still holds the Agent field as its own on this board, clear it before asking again.');
|
|
380
|
+
links.bind(session, run.ticketId);
|
|
381
|
+
return { summary: issueDossier(issue), branch: branchFor(run.ticketId, issue.title) };
|
|
382
|
+
},
|
|
383
|
+
ready: run => {
|
|
384
|
+
if (!run.sessionId)
|
|
385
|
+
return void log.warn('ready.sessionless', { run: run.id });
|
|
386
|
+
// Absent means the pair was already attached: a replay, said never.
|
|
387
|
+
if (!links.attach(run.ticketId, run.sessionId))
|
|
388
|
+
log.debug('ready.replayed', { run: run.id });
|
|
389
|
+
// The progress stream starts with the pair it can speak through:
|
|
390
|
+
// while the run works, its session is the one edited status line.
|
|
391
|
+
if (config.progress === 'silent' || !tracker.progress)
|
|
392
|
+
return;
|
|
393
|
+
const follower = runProgress.get(run.id) ??
|
|
394
|
+
new RunProgress(tracker, conversationOf(run), run.sessionId, {
|
|
395
|
+
mode: config.progress,
|
|
396
|
+
events: services.events,
|
|
397
|
+
log,
|
|
398
|
+
});
|
|
399
|
+
runProgress.set(run.id, follower);
|
|
400
|
+
follower.start();
|
|
401
|
+
},
|
|
402
|
+
question: async (run, question) => {
|
|
403
|
+
// Parked: nothing happens while a person thinks, and an idle
|
|
404
|
+
// follower would only refresh a line that stands still. Guarded so
|
|
405
|
+
// a run that never streamed says nothing a beat later than before.
|
|
406
|
+
const follower = runProgress.get(run.id);
|
|
407
|
+
if (follower)
|
|
408
|
+
await follower.stop();
|
|
409
|
+
await tracker.ask(conversationOf(run), question);
|
|
410
|
+
},
|
|
411
|
+
plan: async (run, plan) => {
|
|
412
|
+
await tracker.plan(conversationOf(run), plan);
|
|
413
|
+
},
|
|
414
|
+
endWork: async (run) => {
|
|
415
|
+
// The closing is the run's last word: the progress stream falls
|
|
416
|
+
// silent before it, never after.
|
|
417
|
+
const follower = runProgress.get(run.id);
|
|
418
|
+
if (follower)
|
|
419
|
+
await follower.stop();
|
|
420
|
+
runProgress.delete(run.id);
|
|
421
|
+
if (closings.has(run.id))
|
|
422
|
+
return; // the stage and the boot pass may race for one run
|
|
423
|
+
closings.set(run.id, run);
|
|
424
|
+
await driveClosing(run.id);
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
services.orchestrator.addTracker(work);
|
|
428
|
+
/**
|
|
429
|
+
* The assistant: who people reach directly. A hand-set `Agent` value
|
|
430
|
+
* nothing claims is un-taken here; the assistant's response is the trail.
|
|
431
|
+
*/
|
|
432
|
+
const startAssistant = async (issue, project, conversation, promptContext) => {
|
|
433
|
+
store.bind(conversation, {
|
|
434
|
+
agent: assistantAgent(services.loaded.config.plugins['tracker-github']),
|
|
435
|
+
directory: project ? project.directory : home,
|
|
436
|
+
project: project?.id ?? null,
|
|
437
|
+
});
|
|
438
|
+
await say(conversation, `One moment — reading ${issue.identifier}.`, 'progress');
|
|
439
|
+
// The assistant's marks (D10): eyes while it works, swapped for the
|
|
440
|
+
// rocket when the answer lands. Status surface only.
|
|
441
|
+
await client.addReaction(issue.id, 'eyes').catch(error => log.warn('reaction.failed', { error }));
|
|
442
|
+
// Facts only: who is talking and what was brought. How the assistant
|
|
443
|
+
// behaves with them is the agent file's, the whole boundary.
|
|
444
|
+
const situation = `[platform: github; issue: ${issue.identifier} "${issue.title}"; project: ${project?.id ?? 'none'}; came by: a person brought you the issue]`;
|
|
445
|
+
const text = [situation, promptContext ?? issueDossier(issue)].join('\n\n');
|
|
446
|
+
const { session } = tracker.parts(conversation);
|
|
447
|
+
store.enqueue({
|
|
448
|
+
id: `created:${session}`,
|
|
449
|
+
channel: conversation,
|
|
450
|
+
user: tracker.ownerOf(conversation),
|
|
451
|
+
name: 'GitHub',
|
|
452
|
+
text,
|
|
453
|
+
}, 100);
|
|
454
|
+
};
|
|
455
|
+
/** End the assistant turn in `conversation` because the person asked; the session stays. */
|
|
456
|
+
const stopAssistant = async (conversation, why) => {
|
|
457
|
+
const result = await stopRunningTurn(engine, services.opencode, conversation);
|
|
458
|
+
if (result.error)
|
|
459
|
+
log.warn('stop.interrupt_failed', { error: result.error });
|
|
460
|
+
// The engine's stopped notice follows for a running turn; queued-only conversations hear this one.
|
|
461
|
+
if (!result.stopped)
|
|
462
|
+
await say(conversation, why, 'outcome');
|
|
463
|
+
else
|
|
464
|
+
await say(conversation, why, 'note');
|
|
465
|
+
log.info('assistant.stopped', { conversation, why });
|
|
466
|
+
};
|
|
467
|
+
/**
|
|
468
|
+
* A ticket brought to us with nothing known about it. Routing is
|
|
469
|
+
* deterministic, from the issue re-read: a redelivery finds the bound
|
|
470
|
+
* conversation or the live run and stops; a deleted ticket gets nothing
|
|
471
|
+
* at all; the human label refuses for any agent — the tracker decides
|
|
472
|
+
* what needs-human looks like on its platform; a **hand-set `Agent`
|
|
473
|
+
* value** gets one fixed refusal — work reaches me through the board,
|
|
474
|
+
* and the only value my own run sets is folded away by the live-run
|
|
475
|
+
* guard above; and what a person brings us — a comment on the ticket,
|
|
476
|
+
* GitHub's only way in — lands on the assistant, the project's
|
|
477
|
+
* configured assistant, in the fixed context of a project (ruled
|
|
478
|
+
* 2026-10-02).
|
|
479
|
+
*/
|
|
480
|
+
const onBrought = async (conversation, issueId, said) => {
|
|
481
|
+
if (store.has(conversation) || (issueId && services.orchestrator.activeRun(MODULE_ID, issueId)))
|
|
482
|
+
return; // a redelivery
|
|
483
|
+
if (!issueId)
|
|
484
|
+
return refuse(conversation, 'I only work on issues; this has none.');
|
|
485
|
+
const issue = await tracker.issue(GITHUB_FEED, issueId);
|
|
486
|
+
if (issue.archived)
|
|
487
|
+
return log.debug('session.archived', { issue: issue.identifier });
|
|
488
|
+
if (issue.labels.some(l => l.name === config.humanLabel))
|
|
489
|
+
return refuse(conversation, `${issue.identifier} carries the \`${config.humanLabel}\` label, so a person handles it. Remove the label to let me work on it.`);
|
|
490
|
+
const routed = projectForIssue(services.loaded, { repo: issue.teamId });
|
|
491
|
+
if (issue.delegateId === tracker.ownerOf(GITHUB_FEED)) {
|
|
492
|
+
// A hand-set Agent value not initiated by us (ruled 2026-10-02): our
|
|
493
|
+
// own `initWork` value was folded away by the live-run guard above,
|
|
494
|
+
// so whoever named us here did it by hand. One fixed refusal — the
|
|
495
|
+
// value cleared, the answer plain — whatever the lane maps: work
|
|
496
|
+
// reaches me through the board.
|
|
497
|
+
if (issue.completed)
|
|
498
|
+
return refuse(conversation, `${issue.identifier} is already finished as far as work goes; nothing to start.`);
|
|
499
|
+
return void (await unclaimed(issue, 'work reaches me through the board, not through the `Agent` field: I start a worker myself once the ticket sits in a lane I work.'));
|
|
500
|
+
}
|
|
501
|
+
// A person addressing us is a conversation the assistant holds,
|
|
502
|
+
// mapped lanes or not.
|
|
503
|
+
await startAssistant(issue, routed?.project, conversation, said ? `${issueDossier(issue)}\n\nThe person wrote on the ticket: ${said}` : undefined);
|
|
504
|
+
engine?.tick();
|
|
505
|
+
};
|
|
506
|
+
/** A stop signal: the run's stop goes to a person's hands on the board
|
|
507
|
+
* *before* the run ends (ruled 2026-10-03) — "not that one again" is
|
|
508
|
+
* the human label's memory, never one in a database — and the ending
|
|
509
|
+
* clears the `Agent` value and wakes the walk in the same breath. A
|
|
510
|
+
* stop with no run stops the assistant's own turn, if one is running. */
|
|
511
|
+
const onStopSignal = async (conversation, run) => {
|
|
512
|
+
if (run) {
|
|
513
|
+
await markForHuman(GITHUB_FEED, run.ticketId);
|
|
514
|
+
await services.orchestrator.stop(run.id, `A person asked to stop ${run.ticketId} from the ticket.`);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (!store.has(conversation))
|
|
518
|
+
return refuse(conversation, 'I do not know this conversation (it started before aivi did, or its state is gone). Comment on the ticket again.');
|
|
519
|
+
const result = await stopRunningTurn(engine, services.opencode, conversation);
|
|
520
|
+
if (result.error)
|
|
521
|
+
log.warn('stop.interrupt_failed', { error: result.error });
|
|
522
|
+
if (!result.stopped)
|
|
523
|
+
await say(conversation, 'Nothing is running right now.', 'answer');
|
|
524
|
+
};
|
|
525
|
+
/** An answer to a pending form: the orchestrator owns its delivery — the
|
|
526
|
+
* slot may have been given back while the person thought, and an answer
|
|
527
|
+
* that finds no capacity **reacquires** it, in the session's own pool,
|
|
528
|
+
* queue included, before the same session resumes. OpenCode first, the
|
|
529
|
+
* books second (ruled 2026-10-03): the worker gets the text, then the
|
|
530
|
+
* form closes as the record. */
|
|
531
|
+
const deliverAnswer = async (conversation, run, text, formId) => {
|
|
532
|
+
let outcome;
|
|
533
|
+
try {
|
|
534
|
+
outcome = await services.orchestrator.answer(run.sessionId, text, formId);
|
|
535
|
+
}
|
|
536
|
+
catch (error) {
|
|
537
|
+
// OpenCode would not take the answer. Nothing moved — the run
|
|
538
|
+
// is still parked, the question still stands — and the person
|
|
539
|
+
// hears that now, in these words, not by silence.
|
|
540
|
+
log.warn('answer.delivery.failed', { run: run.id, error });
|
|
541
|
+
return refuse(conversation, 'I could not reach my agent runtime to wake the worker — the question still stands; answer it again once the runtime is back.');
|
|
542
|
+
}
|
|
543
|
+
if ('refused' in outcome)
|
|
544
|
+
return refuse(conversation, 'The worker’s slot is gone and no capacity is free to wake it again — the pool behind its lane is full and its one queue place is taken. The question still stands: answer it again once a slot opens.');
|
|
545
|
+
if ('queued' in outcome)
|
|
546
|
+
await say(conversation, 'I have your answer. No slot is free to wake that worker yet, so it wakes the moment one opens.', 'progress');
|
|
547
|
+
// Work resumes (now or when the slot opens): the progress stream resumes with it.
|
|
548
|
+
runProgress.get(run.id)?.start();
|
|
549
|
+
return log.info('run.answered', { run: run.id, form: formId, ...outcome });
|
|
550
|
+
};
|
|
551
|
+
/** An interjection steers (ruled 2026-10-01): the message lands between
|
|
552
|
+
* turns and steers what comes next. With no turn to steer, the same
|
|
553
|
+
* message queues instead; when both ways fail the person is told the
|
|
554
|
+
* message did not land — never silence for a message that is gone. */
|
|
555
|
+
const steerInterjection = async (conversation, run, client2, text) => {
|
|
556
|
+
try {
|
|
557
|
+
await client2.session.prompt({
|
|
558
|
+
sessionID: run.sessionId,
|
|
559
|
+
id: `msg_${crypto.randomUUID()}`,
|
|
560
|
+
text,
|
|
561
|
+
delivery: 'steer',
|
|
562
|
+
});
|
|
563
|
+
return log.info('run.interjected', { run: run.id });
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
log.warn('run.steer.failed', { run: run.id, error });
|
|
567
|
+
try {
|
|
568
|
+
await client2.session.prompt({
|
|
569
|
+
sessionID: run.sessionId,
|
|
570
|
+
id: `msg_${crypto.randomUUID()}`,
|
|
571
|
+
text,
|
|
572
|
+
delivery: 'queue',
|
|
573
|
+
});
|
|
574
|
+
return log.info('run.interjected.queued', { run: run.id });
|
|
575
|
+
}
|
|
576
|
+
catch (queueError) {
|
|
577
|
+
// Both ways in failed. The delivery was acknowledged to GitHub
|
|
578
|
+
// already, so nothing resends it: if the person hears nothing the
|
|
579
|
+
// message is gone with only a log line. The conversation hears
|
|
580
|
+
// that it did not land, and can say it again.
|
|
581
|
+
log.error('run.interjection.lost', { run: run.id, error: queueError });
|
|
582
|
+
return refuse(conversation, 'I could not carry that message — my agent runtime would not take it, to steer or to queue. Say it again.');
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
/** The follow-up a finished run's conversation gets: the assistant
|
|
587
|
+
* answers in its own session, with the ticket read afresh. */
|
|
588
|
+
const followUp = async (conversation, record, text) => {
|
|
589
|
+
const issue = await tracker.issue(GITHUB_FEED, record.ticketId);
|
|
590
|
+
const routed = projectForIssue(services.loaded, { repo: issue.teamId });
|
|
591
|
+
await startAssistant(issue, routed?.project, conversation, `[follow-up: the run on this ticket ended (${record.state})${record.outcome ? `: ${record.outcome.kind === 'success' ? record.outcome.summary : record.outcome.reason}` : ''}. The person now says: ${text}]`);
|
|
592
|
+
engine?.tick();
|
|
593
|
+
};
|
|
594
|
+
/**
|
|
595
|
+
* A person's message on a ticket — GitHub's only way into a
|
|
596
|
+
* conversation. If a run lives on the ticket, the **orchestrator is not
|
|
597
|
+
* in this path**: we post into the OpenCode session ourselves, and the
|
|
598
|
+
* open form is the discriminator — a pending form makes the message an
|
|
599
|
+
* ANSWER (the worker gets the text, the form closes as the record;
|
|
600
|
+
* OpenCode resumes the session), its absence an INTERJECTION steered
|
|
601
|
+
* into the running turn — never read from the words. Otherwise the
|
|
602
|
+
* assistant holds the conversation, and a follow-up to a finished run is
|
|
603
|
+
* the assistant answering about a ticket it can read afresh. What
|
|
604
|
+
* nothing knows is a ticket brought to us.
|
|
605
|
+
*/
|
|
606
|
+
const onPrompted = async (event) => {
|
|
607
|
+
const conversation = event.conversation;
|
|
608
|
+
const ref = tracker.ticketOf(conversation);
|
|
609
|
+
if (!ref)
|
|
610
|
+
return refuse(conversation, 'I do not know this conversation.');
|
|
611
|
+
const record = services.orchestrator.activeRun(MODULE_ID, ref) ??
|
|
612
|
+
(() => {
|
|
613
|
+
const link = links.byTicket(ref);
|
|
614
|
+
return link?.opencodeSession ? services.orchestrator.runBySession(link.opencodeSession) : undefined;
|
|
615
|
+
})();
|
|
616
|
+
// Only a run still live on the ticket makes this a working session. A
|
|
617
|
+
// finished one is the assistant's ground: a message into a completed
|
|
618
|
+
// conversation is a follow-up, not an answer or an interjection.
|
|
619
|
+
const run = record && !isTerminal(record.state) ? record : undefined;
|
|
620
|
+
if (event.signal === 'stop')
|
|
621
|
+
return onStopSignal(conversation, run);
|
|
622
|
+
const text = event.body?.trim();
|
|
623
|
+
if (!text)
|
|
624
|
+
return;
|
|
625
|
+
if (run?.sessionId) {
|
|
626
|
+
const opencode = await services.opencode();
|
|
627
|
+
const [form] = await opencode.session.form.list({ sessionID: run.sessionId });
|
|
628
|
+
if (form)
|
|
629
|
+
return deliverAnswer(conversation, run, text, form.id);
|
|
630
|
+
return steerInterjection(conversation, run, opencode, text);
|
|
631
|
+
}
|
|
632
|
+
if (store.has(conversation)) {
|
|
633
|
+
store.enqueue({ id: event.id, channel: conversation, user: GITHUB_FEED, name: 'a person on GitHub', text }, 100);
|
|
634
|
+
return engine?.tick();
|
|
635
|
+
}
|
|
636
|
+
// The ticket belongs to a run that has ended: a person continuing a
|
|
637
|
+
// finished conversation is talking to us.
|
|
638
|
+
if (record && isTerminal(record.state))
|
|
639
|
+
return followUp(conversation, record, text);
|
|
640
|
+
// Nothing knows this ticket: a person brought it to us.
|
|
641
|
+
return onBrought(conversation, ref, text);
|
|
642
|
+
};
|
|
643
|
+
/** The assistant conversations the update orphaned: the human label, a
|
|
644
|
+
* lane move or an `Agent` change each stops only its own kind of change. */
|
|
645
|
+
const stopOrphans = async (pending, issue, lane, human, changed) => {
|
|
646
|
+
for (const conversation of pending) {
|
|
647
|
+
const workerAgent = store.sessionOf(conversation)?.agent;
|
|
648
|
+
if (issue.archived && changed.includes('archive'))
|
|
649
|
+
await stopAssistant(conversation, `Stopped: ${issue.identifier} was deleted; there is no work to do.`);
|
|
650
|
+
else if (human && changed.includes('labels'))
|
|
651
|
+
await stopAssistant(conversation, `Stopped: \`${config.humanLabel}\` was added to ${issue.identifier}; a person takes over.`);
|
|
652
|
+
else if (changed.includes('state') && lane?.agent !== workerAgent)
|
|
653
|
+
await stopAssistant(conversation, `Stopped: ${issue.identifier} moved to "${issue.state.name}", which is not my lane.`);
|
|
654
|
+
else if (changed.includes('delegate') && issue.delegateId !== tracker.ownerOf(GITHUB_FEED))
|
|
655
|
+
await stopAssistant(conversation, `Stopped: I am no longer the delegate of ${issue.identifier}.`);
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
/** The run the update orphaned — same three reasons: interrupt the
|
|
659
|
+
* worker and end the run cancelled; the ending's catch-up says so in
|
|
660
|
+
* the status comment. */
|
|
661
|
+
const stopOrphanRun = async (issue, lane, human, changed) => {
|
|
662
|
+
const run = services.orchestrator.activeRun(MODULE_ID, issue.id);
|
|
663
|
+
if (!run)
|
|
664
|
+
return;
|
|
665
|
+
const link = run.sessionId ? links.byOpencodeSession(run.sessionId) : undefined;
|
|
666
|
+
if (issue.archived && changed.includes('archive'))
|
|
667
|
+
await services.orchestrator.stop(run.id, `Stopped: ${issue.identifier} was deleted; there is no work.`);
|
|
668
|
+
else if (human && changed.includes('labels'))
|
|
669
|
+
await services.orchestrator.stop(run.id, `Stopped: \`${config.humanLabel}\` was added to ${issue.identifier}; a person takes over.`);
|
|
670
|
+
else if (changed.includes('state') && lane?.agent !== run.agent)
|
|
671
|
+
await services.orchestrator.stop(run.id, `Stopped: ${issue.identifier} moved to "${issue.state.name}", which is not my lane.`);
|
|
672
|
+
else if (link && changed.includes('delegate') && issue.delegateId !== tracker.ownerOf(GITHUB_FEED)) {
|
|
673
|
+
// A person took the ticket back: it carries their hands' label
|
|
674
|
+
// **before** the stop wakes the walk (ruled 2026-10-03), or aivi
|
|
675
|
+
// would re-delegate itself in the same breath the human cleared the
|
|
676
|
+
// field.
|
|
677
|
+
await markForHuman(GITHUB_FEED, issue.id);
|
|
678
|
+
await services.orchestrator.stop(run.id, `Stopped: I am no longer the delegate of ${issue.identifier}.`);
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
const onIssue = async (event) => {
|
|
682
|
+
if (!event.changed.length)
|
|
683
|
+
return;
|
|
684
|
+
let issue;
|
|
685
|
+
try {
|
|
686
|
+
issue = await tracker.issue(GITHUB_FEED, event.issueId);
|
|
687
|
+
}
|
|
688
|
+
catch (error) {
|
|
689
|
+
if (!isGoneError(error))
|
|
690
|
+
throw error;
|
|
691
|
+
// A `deleted` delivery and a ticket that answers nothing are the
|
|
692
|
+
// same fact: whatever still works it stops.
|
|
693
|
+
const run = services.orchestrator.activeRun(MODULE_ID, event.issueId);
|
|
694
|
+
if (run)
|
|
695
|
+
await services.orchestrator.stop(run.id, `Stopped: ${event.issueId} was deleted; there is no work.`);
|
|
696
|
+
for (const conversation of store.pendingForIssue(event.issueId))
|
|
697
|
+
await stopAssistant(conversation, `Stopped: ${event.issueId} was deleted; there is no work to do.`);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
const routed = projectForIssue(services.loaded, { repo: issue.teamId });
|
|
701
|
+
if (!routed)
|
|
702
|
+
return;
|
|
703
|
+
const lane = laneOf(routed.project, issue.state.name);
|
|
704
|
+
const human = issue.labels.some(l => l.name === config.humanLabel);
|
|
705
|
+
// A hand-set `Agent` value with no run working the ticket: the fixed
|
|
706
|
+
// refusal (work reaches me through the board). Our own `initWork`
|
|
707
|
+
// value finds its live run and passes; an ending's clearing names
|
|
708
|
+
// nobody and passes.
|
|
709
|
+
if (event.changed.includes('delegate') &&
|
|
710
|
+
issue.delegateId === tracker.ownerOf(GITHUB_FEED) &&
|
|
711
|
+
!services.orchestrator.activeRun(MODULE_ID, issue.id)) {
|
|
712
|
+
await unclaimed(issue, 'work reaches me through the board, not through the `Agent` field: I start a worker myself once the ticket sits in a lane I work.');
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
await stopOrphanRun(issue, lane, human, event.changed);
|
|
716
|
+
const pending = store.pendingForIssue(issue.id);
|
|
717
|
+
await stopOrphans(pending, issue, lane, human, event.changed);
|
|
718
|
+
// Anything a person does to a ticket's place or standing wakes the
|
|
719
|
+
// walk — **unless the ticket sits where aivi does not look**: a human
|
|
720
|
+
// lane that is not the queue, or a Status option not in the array at
|
|
721
|
+
// all. Out of sight, out of mind — the event is ignored, not failed.
|
|
722
|
+
// A run the move stranded was already stopped above, and a stop wakes
|
|
723
|
+
// the walk itself.
|
|
724
|
+
if (event.changed.includes('state') || event.changed.includes('labels')) {
|
|
725
|
+
services.orchestrator.cancelWaiting(MODULE_ID, issue.id); // a person's move cancels a waiting request
|
|
726
|
+
if (lane === undefined || (lane.agent === undefined && !lane.queue))
|
|
727
|
+
return;
|
|
728
|
+
void services.orchestrator.wake(routed.project.id); // a lane move is a wake and nothing more
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
/** Everything downstream keys off the adapter's normalized events — the
|
|
732
|
+
* endpoint, the signature and the acknowledgement are the adapter's.
|
|
733
|
+
* A handler that throws is logged and dropped: the delivery was
|
|
734
|
+
* acknowledged already, so a retry would only replay what failed. */
|
|
735
|
+
const unsubscribeEvents = tracker.events(async (event) => {
|
|
736
|
+
try {
|
|
737
|
+
if (abort.signal.aborted)
|
|
738
|
+
return;
|
|
739
|
+
if (event.kind === 'started')
|
|
740
|
+
await onBrought(event.conversation, event.issueId, event.promptContext);
|
|
741
|
+
else if (event.kind === 'prompted')
|
|
742
|
+
await onPrompted(event);
|
|
743
|
+
else
|
|
744
|
+
await onIssue(event);
|
|
745
|
+
}
|
|
746
|
+
catch (error) {
|
|
747
|
+
log.warn('event.failed', { error });
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
const unregisterRoute = registerWebhookRoute(services.routes, tracker, log);
|
|
751
|
+
// An assistant turn interrupted by a restart is blocked (nobody knows whether the agent stopped); say so on its ticket.
|
|
752
|
+
for (const turn of interrupted)
|
|
753
|
+
await say(turn.channel, 'aivi was restarted while I was working. The OpenCode session is left as it is; an operator must inspect and resolve this before the project is worked on again.', 'outcome');
|
|
754
|
+
// Our boot pass, ours alone: every pair whose closing has **not landed**
|
|
755
|
+
// and whose run has ended owes GitHub its closing — the final comment
|
|
756
|
+
// may have died in an outage. Each step asks GitHub's real state first,
|
|
757
|
+
// so a catch-up that already happened says nothing twice, and a pair
|
|
758
|
+
// whose closing landed is settled — never asked again. The lane moves
|
|
759
|
+
// are the orchestrator's own debt and its boot pass re-drives them.
|
|
760
|
+
for (const link of links.owed()) {
|
|
761
|
+
const run = services.orchestrator.runBySession(link.opencodeSession);
|
|
762
|
+
if (run && isTerminal(run.state)) {
|
|
763
|
+
closings.set(run.id, run);
|
|
764
|
+
await driveClosing(run.id).catch(() => { });
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const unregister = services.channels.register({
|
|
768
|
+
id: GITHUB.id,
|
|
769
|
+
accepts: () => false,
|
|
770
|
+
ownsSession: session => store.channelOf(session) !== null,
|
|
771
|
+
channelOf: async () => undefined,
|
|
772
|
+
async reenter(session, text, context) {
|
|
773
|
+
store.enqueueJobResult(context.run.id, session, text, 100);
|
|
774
|
+
engine?.tick();
|
|
775
|
+
},
|
|
776
|
+
async post() {
|
|
777
|
+
throw new Error('GitHub is not a report destination; report to a chat channel instead');
|
|
778
|
+
},
|
|
779
|
+
});
|
|
780
|
+
const unsubscribeWake = services.onWake(() => {
|
|
781
|
+
engine.tick();
|
|
782
|
+
void retryClosings();
|
|
783
|
+
});
|
|
784
|
+
engine.tick();
|
|
785
|
+
log.info('ready', { org: client.org, app: client.delegateName });
|
|
786
|
+
return {
|
|
787
|
+
async stop() {
|
|
788
|
+
unsubscribeEvents();
|
|
789
|
+
unregisterRoute();
|
|
790
|
+
unregister();
|
|
791
|
+
unsubscribeWake();
|
|
792
|
+
await teardown();
|
|
793
|
+
},
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
catch (error) {
|
|
797
|
+
await teardown();
|
|
798
|
+
throw error;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
/** For `aivi github status`: what each assistant conversation is doing.
|
|
802
|
+
* Workers live in the orchestrator's ledger now; their lines join this view
|
|
803
|
+
* when the status command is formalized with the extraction. */
|
|
804
|
+
export function describeWorkers(store) {
|
|
805
|
+
return store
|
|
806
|
+
.list()
|
|
807
|
+
.filter(t => !['sent', 'discarded'].includes(t.state))
|
|
808
|
+
.map(t => {
|
|
809
|
+
const binding = store.sessionOf(t.channel);
|
|
810
|
+
return {
|
|
811
|
+
conversation: t.channel,
|
|
812
|
+
agent: binding?.agent ?? null,
|
|
813
|
+
issue: binding?.issue ?? null,
|
|
814
|
+
turn: t,
|
|
815
|
+
};
|
|
816
|
+
});
|
|
817
|
+
}
|