@aivi/tracker-linear 0.0.0-stage → 0.4.1

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