wicked-crew 0.8.0 → 0.8.2
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/api/ask-paths.d.ts +90 -0
- package/dist/api/ask-paths.d.ts.map +1 -0
- package/dist/api/ask-paths.js +145 -0
- package/dist/api/ask-paths.js.map +1 -0
- package/dist/api/ask-relay.d.ts +150 -0
- package/dist/api/ask-relay.d.ts.map +1 -0
- package/dist/api/ask-relay.js +601 -0
- package/dist/api/ask-relay.js.map +1 -0
- package/dist/api/chat-scope.d.ts +25 -2
- package/dist/api/chat-scope.d.ts.map +1 -1
- package/dist/api/chat-scope.js +47 -4
- package/dist/api/chat-scope.js.map +1 -1
- package/dist/api/diagnostics.d.ts +5 -2
- package/dist/api/diagnostics.d.ts.map +1 -1
- package/dist/api/diagnostics.js +7 -3
- package/dist/api/diagnostics.js.map +1 -1
- package/dist/api/eval-sample.d.ts +12 -12
- package/dist/api/mcp.d.ts +10 -10
- package/dist/api/plan-schema.d.ts +14 -0
- package/dist/api/plan-schema.d.ts.map +1 -1
- package/dist/api/plan-schema.js +4 -0
- package/dist/api/plan-schema.js.map +1 -1
- package/dist/api/recording.d.ts +30 -1
- package/dist/api/recording.d.ts.map +1 -1
- package/dist/api/recording.js +54 -6
- package/dist/api/recording.js.map +1 -1
- package/dist/api/roster-standing.d.ts.map +1 -1
- package/dist/api/roster-standing.js +6 -2
- package/dist/api/roster-standing.js.map +1 -1
- package/dist/api/routes.d.ts +144 -14
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +341 -202
- package/dist/api/routes.js.map +1 -1
- package/dist/api/seat-standing.d.ts +9 -1
- package/dist/api/seat-standing.d.ts.map +1 -1
- package/dist/api/seat-standing.js +27 -1
- package/dist/api/seat-standing.js.map +1 -1
- package/dist/api/server.d.ts +5 -0
- package/dist/api/server.d.ts.map +1 -1
- package/dist/api/server.js +76 -5
- package/dist/api/server.js.map +1 -1
- package/dist/api/testing.d.ts +4 -4
- package/dist/campaigns/routes.d.ts +2 -2
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +9 -3
- package/dist/cli/index.js.map +1 -1
- package/dist/core/adapter.d.ts +1 -0
- package/dist/core/adapter.d.ts.map +1 -1
- package/dist/core/adapter.js +8 -0
- package/dist/core/adapter.js.map +1 -1
- package/dist/core/bridge-reaper.d.ts +50 -7
- package/dist/core/bridge-reaper.d.ts.map +1 -1
- package/dist/core/bridge-reaper.js +242 -42
- package/dist/core/bridge-reaper.js.map +1 -1
- package/dist/core/types.d.ts +2 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/decisions/chat-recorder.d.ts +18 -0
- package/dist/decisions/chat-recorder.d.ts.map +1 -1
- package/dist/decisions/chat-recorder.js +107 -14
- package/dist/decisions/chat-recorder.js.map +1 -1
- package/dist/decisions/consider.d.ts +13 -0
- package/dist/decisions/consider.d.ts.map +1 -1
- package/dist/decisions/consider.js +20 -4
- package/dist/decisions/consider.js.map +1 -1
- package/dist/interactive/bridge-pool.d.ts.map +1 -1
- package/dist/interactive/bridge-pool.js +3 -2
- package/dist/interactive/bridge-pool.js.map +1 -1
- package/dist/interactive/draft-events.d.ts +28 -1
- package/dist/interactive/draft-events.d.ts.map +1 -1
- package/dist/interactive/draft-events.js +79 -17
- package/dist/interactive/draft-events.js.map +1 -1
- package/dist/interactive/draft-skill.d.ts.map +1 -1
- package/dist/interactive/draft-skill.js +24 -6
- package/dist/interactive/draft-skill.js.map +1 -1
- package/dist/interactive/review-events.d.ts +1 -1
- package/dist/interactive/review-events.d.ts.map +1 -1
- package/dist/interactive/review-events.js +4 -2
- package/dist/interactive/review-events.js.map +1 -1
- package/dist/studio/assets/index-CGjbTpLl.css +32 -0
- package/dist/studio/assets/index-DRybGZtf.js +580 -0
- package/dist/studio/editors/wicked-page/editor.json +1 -1
- package/dist/studio/editors/wicked-page/index.html +1 -1
- package/dist/studio/index.html +2 -2
- package/dist/studio/testid-inventory.json +499 -19
- package/dist/team/routes.d.ts +38 -0
- package/dist/team/routes.d.ts.map +1 -1
- package/endpoint-manifest.json +1 -1
- package/package.json +3 -3
- package/dist/studio/assets/index-BnJlrMxp.css +0 -32
- package/dist/studio/assets/index-DG0nwF7c.js +0 -582
package/dist/api/routes.js
CHANGED
|
@@ -19,10 +19,11 @@ import { execCapped, ExecOutputTooLarge } from '../core/exec.js';
|
|
|
19
19
|
import { callEstateTool, EstateMcpError } from '../core/estate-mcp-client.js';
|
|
20
20
|
import { SeatHealthTracker } from './seat-health.js';
|
|
21
21
|
import { applyWorkerConfigRoot, signedInHeuristic } from './seat-signin.js';
|
|
22
|
-
import { chatSeatAdmission
|
|
22
|
+
import { chatSeatAdmission } from './seat-standing.js';
|
|
23
23
|
import { rosterWithStandingFactory } from './roster-standing.js';
|
|
24
24
|
import { ChatTurnIndex } from './chat-turns.js';
|
|
25
|
-
import { ChatScopeIndex, chatScopeDeps, prepareChatScratch, removeChatScratch, namedKindRefusal, resolveChatScope, } from './chat-scope.js';
|
|
25
|
+
import { ChatScopeIndex, chatScopeDeps, chatScopeStatement, prepareChatScratch, removeChatScratch, namedKindRefusal, resolveChatScope, } from './chat-scope.js';
|
|
26
|
+
import { AskPathIndex, answerStep } from './ask-paths.js';
|
|
26
27
|
import { allowedRootsFor, isInsideRoot, openWithSystemDefault } from './open-path.js';
|
|
27
28
|
import { InvalidDiffBaseError, MERGE_BASE_LITERAL, NotARegularFileError, UnresolvableDiffBaseError, branchDiff, isGitStopped, isPlainRef, readFileCapped, worktreeDiff, } from './run-files.js';
|
|
28
29
|
import { DocRunIndex } from '../interactive/doc-run-index.js';
|
|
@@ -538,6 +539,8 @@ export const ChatOpenSchema = z.object({
|
|
|
538
539
|
/** The scope the caller NAMES (studio#323 R4) — omitted ⇒ the legacy inference. Shape rules
|
|
539
540
|
* (which kinds need / refuse `projectId` / `repoRefs`) are the resolver's (`chat-scope.ts`). */
|
|
540
541
|
scopeKind: z.enum(['system', 'everything', 'project', 'repo', 'repos', 'none']).optional(),
|
|
542
|
+
/** (ASK-C1) The seat the operator chose as the primary agent; omitted ⇒ the engine picks at random. */
|
|
543
|
+
primary: z.string().min(1).optional(),
|
|
541
544
|
}).strict();
|
|
542
545
|
/**
|
|
543
546
|
* The `singleSeat` disclosure (crew#641, hole closed in crew#650) — built in ONE place so the 201
|
|
@@ -606,7 +609,7 @@ at) {
|
|
|
606
609
|
refusalsKnown: known,
|
|
607
610
|
message:
|
|
608
611
|
// "warm" on a turn: the claim is about the roster as it stands, not about how it got there.
|
|
609
|
-
`This chat has one
|
|
612
|
+
`This chat has one eligible seat (${warmed}); it cannot disagree with itself. ` +
|
|
610
613
|
refusalClause +
|
|
611
614
|
'The single-seat root cause is tracked as wicked-core#563; this chat makes it visible.',
|
|
612
615
|
};
|
|
@@ -641,6 +644,7 @@ runtime = {}) {
|
|
|
641
644
|
const guidanceIndex = runtime.guidanceIndex ?? new GuidanceIndex();
|
|
642
645
|
const chatScopes = runtime.chatScopes ?? new ChatScopeIndex();
|
|
643
646
|
const chatTurns = runtime.chatTurns ?? new ChatTurnIndex();
|
|
647
|
+
const askPaths = runtime.askPaths ?? new AskPathIndex();
|
|
644
648
|
const chatTranscripts = runtime.chatTranscripts;
|
|
645
649
|
const deliveryIndex = runtime.deliveryIndex ?? new DeliveryIndex();
|
|
646
650
|
// Wave 6: the doc↔run binding and the registered test sets — `createServer` injects the real
|
|
@@ -962,6 +966,10 @@ runtime = {}) {
|
|
|
962
966
|
: { deliverGate: false, revisesPr: false, chatIdOnLaunch: false, seatChipOnCreate: false }),
|
|
963
967
|
// C1: `AgentSession.chat_id` is served from the daemon's own launch index, whatever the engine.
|
|
964
968
|
runChatId: true,
|
|
969
|
+
// ASK-C1 (DES-ASK-TEAM-CHAT-001 §5.1): an ask starts a path — the whole contract is the
|
|
970
|
+
// pinned core-ts floor the adapter reports; an older studio reads ABSENT as "every helper
|
|
971
|
+
// answers", a newer one renders the one PA voice.
|
|
972
|
+
askPath: typeof adapter.engineCapabilities === 'function' ? adapter.engineCapabilities().askPath : false,
|
|
965
973
|
// WT-W1: repo-bound launches mint an evidence root only on an addon that takes the field.
|
|
966
974
|
walkthroughRoots: typeof adapter.evidenceRootsSupported === 'function' ? adapter.evidenceRootsSupported() : false,
|
|
967
975
|
};
|
|
@@ -1754,7 +1762,8 @@ runtime = {}) {
|
|
|
1754
1762
|
// always has at least one warm seat (`crew-api-types` ChatOpenResponse), so 0 is not a value
|
|
1755
1763
|
// `chat_seat_count` can honestly hold.
|
|
1756
1764
|
if (b.chatId !== undefined) {
|
|
1757
|
-
|
|
1765
|
+
// ASK-C1: a chat's seats are its eligible roster (no warm pool to ask the engine about).
|
|
1766
|
+
const promotedFrom = askPaths.get(b.chatId)?.eligible ?? null;
|
|
1758
1767
|
const scope = chatScopes.engineOf(b.chatId);
|
|
1759
1768
|
if (promotedFrom !== null && promotedFrom.length > 0 && scope !== undefined) {
|
|
1760
1769
|
chatSeatCount = promotedFrom.length;
|
|
@@ -2355,9 +2364,11 @@ runtime = {}) {
|
|
|
2355
2364
|
if (token === null) {
|
|
2356
2365
|
const state = chatScopes.stateOf(chatId);
|
|
2357
2366
|
return reply.code(409).send({
|
|
2358
|
-
error: state === '
|
|
2359
|
-
? `chat ${chatId} is closing
|
|
2360
|
-
:
|
|
2367
|
+
error: state === 'held'
|
|
2368
|
+
? `chat ${chatId} is closing (its DELETE is still settling); retry in a moment, or omit chatId to mint a fresh one`
|
|
2369
|
+
: state === 'closing'
|
|
2370
|
+
? `chat ${chatId} is closing; wait for its chatClosed (a few seconds at most) before reusing the id, or omit chatId to mint a fresh one`
|
|
2371
|
+
: `chat ${chatId} is already open on this daemon; DELETE /chats/${chatId} first, or omit chatId to mint a fresh one`,
|
|
2361
2372
|
});
|
|
2362
2373
|
}
|
|
2363
2374
|
// DC-S7: the in-force rules the statement lists; recorded as "told" only once the open succeeds.
|
|
@@ -2391,9 +2402,17 @@ runtime = {}) {
|
|
|
2391
2402
|
// DC-S7: the seats learn the project's in-force rules from the statement (cap 20, severity
|
|
2392
2403
|
// order); the service remembers what they were told so a later landing can be prefaced.
|
|
2393
2404
|
inForceAtOpen = (await considerations.inForceFor(scope.projectId ?? null)).rules;
|
|
2405
|
+
// The scratch root is shared by id: an open whose reservation a DELETE cancelled while it
|
|
2406
|
+
// was resolving must not write (or later remove) the root a newer open now holds
|
|
2407
|
+
// (codex on #808 r3, 3).
|
|
2408
|
+
if (!chatScopes.owns(chatId, token)) {
|
|
2409
|
+
return reply.code(409).send({ error: `chat ${chatId} was closed while it was being opened; open it again` });
|
|
2410
|
+
}
|
|
2394
2411
|
prepareChatScratch(chatId, scope, inForceAtOpen);
|
|
2395
2412
|
}
|
|
2396
2413
|
catch (err) {
|
|
2414
|
+
// The helper removes only what it created itself (a pre-existing root is preserved) —
|
|
2415
|
+
// nothing more is removed here (codex on #808 r4, 3).
|
|
2397
2416
|
chatScopes.release(chatId, token);
|
|
2398
2417
|
return reply
|
|
2399
2418
|
.code(500)
|
|
@@ -2412,135 +2431,80 @@ runtime = {}) {
|
|
|
2412
2431
|
// and `system` (studio#323 R4: the platform itself, no repository) read none, so they take the
|
|
2413
2432
|
// unscoped admission and open on an engine that predates chat scope.
|
|
2414
2433
|
const scoped = scope.kind !== 'none' && scope.kind !== 'system';
|
|
2434
|
+
// ASK-C2: a single-repo ask launches BOUND (the run holds the repository read-only), so every
|
|
2435
|
+
// seat in standing is eligible; a wider repository scope keeps the structural rule.
|
|
2436
|
+
const admissionKind = scope.kind === 'repos' && scope.repos.length === 1 ? 'path-bound' : 'path';
|
|
2415
2437
|
const refused = [];
|
|
2416
2438
|
// The standing roster, read ONCE: the default admission below and the engine-drop
|
|
2417
2439
|
// attribution after `chatOpen` both consult it (F-A45-011).
|
|
2418
2440
|
const standing = rosterWithStanding();
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2441
|
+
// ASK-C1: admission applies to the DEFAULT roster and to seats NAMED in `clis` alike — the
|
|
2442
|
+
// engine no longer refuses a seat at open (nothing is warmed), so this is the only gate
|
|
2443
|
+
// between a request and the eligible roster the path launches with. The sign-in standing
|
|
2444
|
+
// is part of it for a named seat too: the launch hands the engine the same standing roster,
|
|
2445
|
+
// which benches a signed-out seat (codex on #808 r2, 5) — admitting it here would only move
|
|
2446
|
+
// the refusal to a launch that cannot start. An unknown key is refused by name.
|
|
2447
|
+
const candidates = b.clis !== undefined
|
|
2448
|
+
? b.clis.map((key) => standing.find((s) => String(s.key) === key) ?? { key })
|
|
2449
|
+
: standing;
|
|
2450
|
+
const clis = [];
|
|
2451
|
+
for (const seat of candidates) {
|
|
2452
|
+
const key = String(seat.key);
|
|
2453
|
+
if (!('auth' in seat)) {
|
|
2454
|
+
refused.push({ cliKey: key, reason: `seat '${key}' is not in the roster`, source: 'scope' });
|
|
2455
|
+
continue;
|
|
2433
2456
|
}
|
|
2457
|
+
const admission = chatSeatAdmission(seat, seat.auth ?? 'unknown', scoped, admissionKind);
|
|
2458
|
+
if (admission.ok)
|
|
2459
|
+
clis.push(key);
|
|
2460
|
+
else
|
|
2461
|
+
refused.push({ cliKey: key, reason: admission.reason, source: admission.source });
|
|
2434
2462
|
}
|
|
2435
2463
|
if (clis.length === 0) {
|
|
2436
|
-
chatScopes.release(chatId, token)
|
|
2437
|
-
|
|
2464
|
+
if (chatScopes.release(chatId, token))
|
|
2465
|
+
removeChatScratch(scope.cwd, chatScopes.base);
|
|
2438
2466
|
return reply.code(409).send({
|
|
2439
|
-
error: (scoped
|
|
2440
|
-
? 'no seat in
|
|
2441
|
-
'
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
'the System page, or name seats with `clis`') + ' — see refused',
|
|
2467
|
+
error: (scoped && admissionKind === 'path'
|
|
2468
|
+
? 'no seat in standing can be held to an ask that reads several repositories (or a project) ' +
|
|
2469
|
+
'the run cannot bind; scope the ask to one repository, sign a seat in, or open it unscoped'
|
|
2470
|
+
: 'no seat in the roster can take a turn (every seat is signed out or disabled); sign a seat ' +
|
|
2471
|
+
'in from the System page, or name seats with `clis`') + ' — see refused',
|
|
2445
2472
|
refused,
|
|
2446
2473
|
});
|
|
2447
2474
|
}
|
|
2448
2475
|
try {
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
// F-A45-011 (F-2R2-007 still open): a seat the engine DROPPED — absent from `seats`
|
|
2459
|
-
// altogether, neither ok nor refused — used to vanish without a trace (the fresh rig's pi:
|
|
2460
|
-
// default chips claude·pi·opencode, 201 `seats:[claude, opencode]`, `refused: []`). It was
|
|
2461
|
-
// taken out by the council-bench / dispatch-timeout path, not by admission, so admission's
|
|
2462
|
-
// list never named it. Every requested-or-defaulted seat that is not in `seats` is named
|
|
2463
|
-
// here with the most specific cause the daemon knows: its own "no credential" report
|
|
2464
|
-
// (`auth`), else the dispatch budget (`budget`). (R5b: crew keeps no council bench of its
|
|
2465
|
-
// own any more — the engine benches per run and says so in `unitDistributed.degradedReason`.)
|
|
2466
|
-
for (const key of clis) {
|
|
2467
|
-
if (seats.some((s) => s.cliKey === key) || refused.some((r) => r.cliKey === key))
|
|
2468
|
-
continue;
|
|
2469
|
-
const st = standingOf(key);
|
|
2470
|
-
const authFailure = seatHealth.authFailureFor(key);
|
|
2471
|
-
if (authFailure !== null || st?.auth === 'signed_out') {
|
|
2472
|
-
refused.push({
|
|
2473
|
-
cliKey: key,
|
|
2474
|
-
reason: authFailure !== null
|
|
2475
|
-
? `not seated — the seat itself reported no credential (${noCredentialCause(authFailure)}); sign it in from the System page`
|
|
2476
|
-
: 'not seated — signed out; sign it in from the System page',
|
|
2477
|
-
source: 'auth',
|
|
2478
|
-
});
|
|
2479
|
-
}
|
|
2480
|
-
else {
|
|
2481
|
-
refused.push({
|
|
2482
|
-
cliKey: key,
|
|
2483
|
-
reason: 'not seated — the engine did not warm it within its dispatch budget (the seat timed out or ' +
|
|
2484
|
-
'was dropped at dispatch); check GET /roster and try again, or name seats with `clis`',
|
|
2485
|
-
source: 'budget',
|
|
2486
|
-
});
|
|
2487
|
-
}
|
|
2488
|
-
}
|
|
2489
|
-
// Nothing warmed (independent review, W1): the engine holds no pool row and has dropped the
|
|
2490
|
-
// scope itself (no `chatClosed` will come) — report the PER-SEAT reasons, never an
|
|
2491
|
-
// engine-version guess; the root goes, the id is free again.
|
|
2492
|
-
if (seats.every((s) => !s.ok)) {
|
|
2493
|
-
chatScopes.release(chatId, token);
|
|
2494
|
-
removeChatScratch(scope.cwd, chatScopes.base);
|
|
2495
|
-
return reply.code(409).send({
|
|
2496
|
-
error: `chat ${chatId}: no seat warmed (${seats.length} failed) — see seats`,
|
|
2497
|
-
seats,
|
|
2476
|
+
// DES-ASK-TEAM-CHAT-001 §5.1 (ASK-C1): an open resolves the scope and ELIGIBILITY and warms
|
|
2477
|
+
// nothing — the chat is a path its first message launches, and the engine picks the PA
|
|
2478
|
+
// then (`path.started.selection`). Crew owns eligibility; the engine owns the pick.
|
|
2479
|
+
if (b.primary !== undefined && !clis.includes(b.primary)) {
|
|
2480
|
+
if (chatScopes.release(chatId, token))
|
|
2481
|
+
removeChatScratch(scope.cwd, chatScopes.base);
|
|
2482
|
+
return reply.code(400).send({
|
|
2483
|
+
error: `primary names a seat that is not eligible for this chat: ${b.primary}` +
|
|
2484
|
+
` (eligible: ${clis.join(', ')})`,
|
|
2498
2485
|
refused,
|
|
2499
2486
|
});
|
|
2500
2487
|
}
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
// the scratch root; an engine that cannot be asked has confirmed nothing. Neither may hold a
|
|
2506
|
-
// scoped chat: it is closed again, its root removed, and the caller gets a 501 naming the
|
|
2507
|
-
// remedy — never a chat whose statement promises what its seats do not enforce. An UNSCOPED
|
|
2508
|
-
// chat (`kind: 'none'`) promises nothing beyond its scratch root and proceeds.
|
|
2509
|
-
const applied = await adapter.chatScopeApplied(chatId);
|
|
2510
|
-
if (applied !== true && scoped) {
|
|
2511
|
-
await adapter.chatClose(chatId).catch(() => undefined);
|
|
2512
|
-
// The engine's `chatClosed` for this id is still on its way: park, do not release
|
|
2513
|
-
// (Copilot, #518), so a reuse cannot have its root removed by the late event.
|
|
2514
|
-
chatScopes.abortToClosing(chatId, token);
|
|
2515
|
-
removeChatScratch(scope.cwd, chatScopes.base);
|
|
2516
|
-
if (applied === false) {
|
|
2517
|
-
// A row WITHOUT the scope fields: the engine predates chat scope (wicked-core#410).
|
|
2518
|
-
return reply.code(501).send({
|
|
2519
|
-
error: 'the installed wicked-core-ts predates chat scope (wicked-core#410): it cannot ground ' +
|
|
2520
|
-
'a scoped chat or hold its read roots read-only — upgrade the engine, or open the ' +
|
|
2521
|
-
'chat without projectId/repoRefs.',
|
|
2522
|
-
seats,
|
|
2523
|
-
});
|
|
2524
|
-
}
|
|
2525
|
-
// NO row although a seat reported warm: nothing is actually held for this chat.
|
|
2526
|
-
return reply.code(409).send({
|
|
2527
|
-
error: `chat ${chatId}: a seat reported warm but the engine holds no row for the chat — ` +
|
|
2528
|
-
'nothing warmed; open it again (see seats)',
|
|
2529
|
-
seats,
|
|
2530
|
-
});
|
|
2531
|
-
}
|
|
2532
|
-
// File the chat into its project WHILE the id is still reserved (Copilot, #518): publishing
|
|
2533
|
-
// first would let a concurrent DELETE / engine `chatClosed` land during this await and leave
|
|
2534
|
-
// a 201 with a stale scope and a membership attached to a closed chat.
|
|
2488
|
+
const seats = clis.map((cliKey) => ({
|
|
2489
|
+
cliKey,
|
|
2490
|
+
ok: true,
|
|
2491
|
+
}));
|
|
2535
2492
|
let projectAttachError;
|
|
2536
2493
|
let attachedMemberId;
|
|
2494
|
+
// Whether THIS open wrote the chat → project index entry (so its cleanup removes only its
|
|
2495
|
+
// own — a newer open of the id may hold one; codex on #808 r4, 2).
|
|
2496
|
+
let indexedHere = false;
|
|
2537
2497
|
if (b.projectId !== undefined) {
|
|
2538
2498
|
try {
|
|
2539
2499
|
const { member, created } = await adapter.projectMemberAttach(b.projectId, 'crew.chat', chatId);
|
|
2540
2500
|
if (created)
|
|
2541
2501
|
attachedMemberId = member.id;
|
|
2542
|
-
|
|
2502
|
+
// The attach awaited: publish the membership only while this open still holds the id —
|
|
2503
|
+
// a cancelled open's filing would overwrite the replacement's (the failed `set` below
|
|
2504
|
+
// detaches what was created).
|
|
2505
|
+
if (created && chatScopes.owns(chatId, token)) {
|
|
2543
2506
|
projects.index.set(chatId, b.projectId);
|
|
2507
|
+
indexedHere = true;
|
|
2544
2508
|
projects.bus?.emit(MEMBERSHIP_ATTACHED, { project_id: b.projectId, member: { kind: 'crew.chat', ref: chatId }, actor: actorOf(req).id }, membershipAttachedKey(b.projectId, 'crew.chat', chatId, member.attached_at));
|
|
2545
2509
|
}
|
|
2546
2510
|
}
|
|
@@ -2552,18 +2516,25 @@ runtime = {}) {
|
|
|
2552
2516
|
}
|
|
2553
2517
|
if (!chatScopes.set(chatId, scope, token, refused, engine)) {
|
|
2554
2518
|
// The reservation was cancelled while the open was in flight — an engine `chatClosed` or a
|
|
2555
|
-
// `DELETE` for this id (Copilot, #518). Nothing was recorded; tear the chat down (
|
|
2556
|
-
//
|
|
2557
|
-
|
|
2558
|
-
|
|
2519
|
+
// `DELETE` for this id (Copilot, #518). Nothing was recorded; tear the chat down (scratch
|
|
2520
|
+
// root and the filing just made) instead of returning a stale 201. No path record exists
|
|
2521
|
+
// yet (it is published below, after the scope is committed), and the scratch root is this
|
|
2522
|
+
// open's only while no newer open holds the id — a replacement opened meanwhile shares the
|
|
2523
|
+
// directory (codex on #808 r2, 3).
|
|
2524
|
+
if (!chatScopes.has(chatId))
|
|
2525
|
+
removeChatScratch(scope.cwd, chatScopes.base);
|
|
2559
2526
|
if (b.projectId !== undefined && attachedMemberId !== undefined) {
|
|
2560
2527
|
await adapter.projectMemberDetach(b.projectId, attachedMemberId).catch(() => false);
|
|
2561
|
-
|
|
2528
|
+
if (indexedHere)
|
|
2529
|
+
projects.index.delete(chatId);
|
|
2562
2530
|
}
|
|
2563
2531
|
return reply.code(409).send({
|
|
2564
2532
|
error: `chat ${chatId} was closed while it was being opened; open it again`,
|
|
2565
2533
|
});
|
|
2566
2534
|
}
|
|
2535
|
+
// ASK-C1: the path record — published only now, with the scope committed, so a message
|
|
2536
|
+
// cannot launch against a scope that is still being filed (codex on #808 r2, 4).
|
|
2537
|
+
askPaths.open(chatId, clis, b.primary);
|
|
2567
2538
|
// Register roots for path rewriting (crew#618): absolute host paths in seat replies are
|
|
2568
2539
|
// rewritten to repo-relative form before being stored in the transcript.
|
|
2569
2540
|
if (chatTranscripts !== undefined && scope.repos.length > 0) {
|
|
@@ -2601,9 +2572,10 @@ runtime = {}) {
|
|
|
2601
2572
|
}
|
|
2602
2573
|
catch (err) {
|
|
2603
2574
|
// Nothing warmed: the scratch root prepared above must not linger — removed under the SAME
|
|
2604
|
-
// base the resolver created it in (Copilot, #518), fail-closed like every removal
|
|
2605
|
-
|
|
2606
|
-
|
|
2575
|
+
// base the resolver created it in (Copilot, #518), fail-closed like every removal; only
|
|
2576
|
+
// while the root is still this open's (codex on #808 r3, 3).
|
|
2577
|
+
if (chatScopes.release(chatId, token))
|
|
2578
|
+
removeChatScratch(scope.cwd, chatScopes.base);
|
|
2607
2579
|
return reply.code(400).send({ error: message(err) });
|
|
2608
2580
|
}
|
|
2609
2581
|
}
|
|
@@ -2643,73 +2615,170 @@ runtime = {}) {
|
|
|
2643
2615
|
if (!parsed.success) {
|
|
2644
2616
|
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
2645
2617
|
}
|
|
2646
|
-
// DES-
|
|
2647
|
-
//
|
|
2648
|
-
//
|
|
2649
|
-
//
|
|
2650
|
-
//
|
|
2651
|
-
//
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
if (
|
|
2657
|
-
return reply.code(409).send({ error: `chat '${id}' has no
|
|
2658
|
-
}
|
|
2659
|
-
const
|
|
2660
|
-
|
|
2661
|
-
|
|
2618
|
+
// DES-ASK-TEAM-CHAT-001 §3, §5.1 (ASK-C1): the first message of a chat LAUNCHES one team path
|
|
2619
|
+
// — a user-composed plan of one `understand` step with its gate raised to the turn gate, one
|
|
2620
|
+
// reviewer asked for, the eligible roster as `clis`, the operator's choice as `primary`; every
|
|
2621
|
+
// later message CONTINUES the same path: `propose_plan(answer-N)` then `confirm_gate(Approve)`
|
|
2622
|
+
// at the turn gate the previous answer paused on. Nothing is fanned out: the PA is the one
|
|
2623
|
+
// voice, helpers talk to the PA, the reviewer raises quiet findings — all on the bus.
|
|
2624
|
+
const path = askPaths.get(id);
|
|
2625
|
+
if (path === undefined) {
|
|
2626
|
+
return reply.code(409).send({ error: `chat '${id}' is not open on this daemon — open it first (POST /chats)` });
|
|
2627
|
+
}
|
|
2628
|
+
if (path.eligible.length === 0) {
|
|
2629
|
+
return reply.code(409).send({ error: `chat '${id}' has no eligible seat — sign a seat in and re-seat it (POST /chats/:id/seats)` });
|
|
2630
|
+
}
|
|
2631
|
+
const text = parsed.data.text;
|
|
2632
|
+
const scope = chatScopes.get(id);
|
|
2633
|
+
// A repo-bound path: exactly one repo in scope grounds the run (`repoRef`) and makes it
|
|
2634
|
+
// deliverable; a project or a multi-repo scope binds the project graph only (§5.1 Grounding).
|
|
2635
|
+
const repoRef = scope !== undefined && scope.kind === 'repos' && scope.repos.length === 1 ? scope.repos[0].id : undefined;
|
|
2636
|
+
const projectId = scope?.projectId;
|
|
2637
|
+
const voice = path.pa !== null ? [path.pa] : [];
|
|
2638
|
+
// The turn is reserved for the voice — or, for a random pick (no PA yet), for the whole
|
|
2639
|
+
// eligible roster: the relay narrows it to the PA when `path.started` names one (ASK-C2), so
|
|
2640
|
+
// the PA's frames are stamped with this turn and a second message is refused meanwhile.
|
|
2641
|
+
const audience = voice.length > 0 ? voice : path.eligible;
|
|
2642
|
+
// crew#641/#650 re-stated for a path: ONE eligible seat means no distinct reviewer and no
|
|
2643
|
+
// helper — said on every turn (the engine's `member.joined{seat:null}` says it on the bus).
|
|
2644
|
+
const singleSeat202 = path.eligible.length === 1
|
|
2645
|
+
? { singleSeat: singleSeatDisclosure(path.eligible[0], chatScopes.refusedOf(id), 'turn') }
|
|
2646
|
+
: {};
|
|
2647
|
+
// F6: a message while the PA is still answering — the previous turn has no reply yet (the
|
|
2648
|
+
// run may already wait at its gate while the relay's reply is a bus poll away; codex on #810
|
|
2649
|
+
// r1, 3) — is refused: nothing is sent, the composer keeps the draft.
|
|
2650
|
+
const pendingTurn = chatTurns.inFlight(id);
|
|
2651
|
+
if (pendingTurn !== null) {
|
|
2652
|
+
return reply.code(409).send(turnInFlightBody(id, pendingTurn, 'Nothing was sent.'));
|
|
2653
|
+
}
|
|
2654
|
+
// One message at a time per chat, on this daemon: the reservation covers the launch, and a
|
|
2655
|
+
// continuation's status read → proposal → approval (two continuations racing would both
|
|
2656
|
+
// read `awaiting_human` and both hold an edit; the second's approval then fails).
|
|
2657
|
+
const lease = askPaths.reserve(id);
|
|
2658
|
+
if (lease === null) {
|
|
2659
|
+
return reply.code(409).send({
|
|
2660
|
+
code: 'turn_in_flight',
|
|
2661
|
+
error: `${path.pa ?? 'the primary agent'} is still answering the previous message in this chat — wait for the reply. Nothing was sent.`,
|
|
2662
|
+
chatId: id,
|
|
2663
|
+
...(path.runId !== undefined ? { runId: path.runId } : {}),
|
|
2664
|
+
});
|
|
2662
2665
|
}
|
|
2663
|
-
|
|
2664
|
-
const
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
const seats = await adapter.chatSend(id, preface !== null ? `${preface}\n\n${parsed.data.text}` : parsed.data.text, parsed.data.targets);
|
|
2671
|
-
if (turn !== null) {
|
|
2672
|
-
// The engine's answer is the truth: the reserved audience is squared with the seats it
|
|
2673
|
-
// reached, and the operator's message joins the transcript with exactly those seats.
|
|
2674
|
-
chatTurns.reconcile(id, turn.turnId, seats);
|
|
2675
|
-
chatTranscripts?.appendUser(id, turn.turnId, parsed.data.text, seats);
|
|
2676
|
-
// DC-S4b: who said it, to whom — the recorder matches the seats' quotes against THIS message.
|
|
2677
|
-
chatRecorder?.noteSend(id, turn.turnId, actorOf(req), parsed.data.text, seats);
|
|
2666
|
+
const turn = chatTurns.begin(id, audience, text);
|
|
2667
|
+
const turnId = turn?.turnId ?? randomUUID();
|
|
2668
|
+
// What an ACCEPTED message records — the engine has it, so a persistence failure here is
|
|
2669
|
+
// logged, never a retraction of a turn that is already being answered (codex on #808 r2, 9).
|
|
2670
|
+
const persistAccepted = (preface, after) => {
|
|
2671
|
+
try {
|
|
2672
|
+
after();
|
|
2678
2673
|
if (preface !== null)
|
|
2679
|
-
chatTranscripts?.appendSystem(id,
|
|
2674
|
+
chatTranscripts?.appendSystem(id, turnId, preface);
|
|
2675
|
+
chatTranscripts?.appendUser(id, turnId, text, voice);
|
|
2676
|
+
// The recorder's audience is the turn's AS THE TURN INDEX HOLDS IT NOW: the eligible roster
|
|
2677
|
+
// for a random pick, or the PA the relay narrowed it to during the engine awaits above
|
|
2678
|
+
// (`path.started` / `path.repicked` may land before this record exists — ASK-C2, codex on
|
|
2679
|
+
// #810 r2, 3).
|
|
2680
|
+
const settled = chatTurns.turnsOf(id).find((t) => t.turnId === turnId)?.seats;
|
|
2681
|
+
const pa = askPaths.get(id)?.pa ?? null;
|
|
2682
|
+
chatRecorder?.noteSend(id, turnId, actorOf(req), text,
|
|
2683
|
+
// The turn index's voice; a turn already ended by an early reply names none, so the
|
|
2684
|
+
// path's PA stands in; the reserved audience last.
|
|
2685
|
+
settled !== undefined && settled.length > 0 ? settled : pa !== null ? [pa] : audience);
|
|
2680
2686
|
}
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2687
|
+
catch (err) {
|
|
2688
|
+
app.log.warn(`chat ${id}: turn ${turnId} was accepted by the engine but not fully recorded: ${message(err)}`);
|
|
2689
|
+
}
|
|
2690
|
+
};
|
|
2691
|
+
// DC-S7: the rules the chat's seats were told about ride the step's instructions, as the
|
|
2692
|
+
// preface rode a pool send; told only once the send is accepted (`commit`).
|
|
2693
|
+
const withPreface = (preface) => (preface !== null ? `${preface}\n\n${text}` : text);
|
|
2694
|
+
try {
|
|
2695
|
+
if (path.runId === undefined) {
|
|
2696
|
+
const stepId = 'answer-1';
|
|
2697
|
+
const generation = path.generation;
|
|
2698
|
+
const roster = rosterWithStanding().filter((seat) => path.eligible.includes(String(seat.key)));
|
|
2699
|
+
const rules = (await considerations.inForceFor(scope?.projectId ?? null)).rules;
|
|
2700
|
+
const pending = await considerations.pendingPreface(id, { inForce: rules });
|
|
2701
|
+
const instructions = withPreface(pending.preface);
|
|
2702
|
+
const input = {
|
|
2703
|
+
problem: scope !== undefined ? chatScopeStatement(id, scope, rules) : text,
|
|
2704
|
+
sessionId: randomUUID(),
|
|
2705
|
+
clisJson: JSON.stringify(roster),
|
|
2706
|
+
plan: { steps: [answerStep(stepId, instructions)], monitors: { asked: 1 } },
|
|
2707
|
+
...(path.primary !== undefined ? { primary: path.primary } : {}),
|
|
2708
|
+
...(repoRef !== undefined ? { repoRef, deliver: 'pr' } : {}),
|
|
2709
|
+
...(projectId !== undefined ? { projectId } : {}),
|
|
2710
|
+
};
|
|
2711
|
+
const runId = await adapter.launchRun(input);
|
|
2712
|
+
if (!askPaths.started(id, generation, runId, stepId)) {
|
|
2713
|
+
// The chat was closed (End) while the launch was in flight: the run must not outlive it.
|
|
2714
|
+
await adapter.cancelRun(runId).catch(() => undefined);
|
|
2715
|
+
if (turn !== null)
|
|
2716
|
+
chatTurns.abort(id, turn.turnId);
|
|
2717
|
+
return reply.code(409).send({ error: `chat '${id}' was closed while its path was launching; the run was cancelled` });
|
|
2718
|
+
}
|
|
2719
|
+
pending.commit();
|
|
2720
|
+
persistAccepted(pending.preface, () => {
|
|
2721
|
+
runtime.linkChatRun?.(id, runId);
|
|
2722
|
+
recordRunLaunched(audit, runTimingIndex, actorOf(req), runId, {
|
|
2723
|
+
chatId: id,
|
|
2724
|
+
askPath: true,
|
|
2725
|
+
...(repoRef !== undefined ? { repoRef } : {}),
|
|
2726
|
+
...(projectId !== undefined ? { projectId } : {}),
|
|
2727
|
+
deliver: repoRef !== undefined ? 'pr' : 'none',
|
|
2728
|
+
});
|
|
2729
|
+
});
|
|
2730
|
+
return reply.code(202).send({ seats: voice, turnId, runId, stepId, ...singleSeat202 });
|
|
2731
|
+
}
|
|
2732
|
+
const view = (await adapter.sessionsDetail()).find((v) => v.session.id === path.runId);
|
|
2733
|
+
if (view === undefined) {
|
|
2734
|
+
askPaths.release(id, lease);
|
|
2735
|
+
if (turn !== null)
|
|
2736
|
+
chatTurns.abort(id, turn.turnId);
|
|
2737
|
+
return reply.code(409).send({ error: `chat '${id}': its path ${path.runId} is gone — open a new chat` });
|
|
2738
|
+
}
|
|
2739
|
+
const status = String(view.session.status);
|
|
2740
|
+
if (status !== 'awaiting_human') {
|
|
2741
|
+
askPaths.release(id, lease);
|
|
2742
|
+
if (turn !== null)
|
|
2743
|
+
chatTurns.abort(id, turn.turnId);
|
|
2744
|
+
const who = path.pa ?? 'the primary agent';
|
|
2745
|
+
return reply.code(409).send({
|
|
2746
|
+
code: 'turn_in_flight',
|
|
2747
|
+
error: status === 'executing' || status === 'running'
|
|
2748
|
+
? `${who} is still answering the previous message in this chat — wait for the reply. Nothing was sent.`
|
|
2749
|
+
: `chat '${id}': its path ${path.runId} is ${status}, not waiting for your turn — nothing was sent`,
|
|
2750
|
+
chatId: id,
|
|
2751
|
+
runId: path.runId,
|
|
2752
|
+
status,
|
|
2753
|
+
});
|
|
2754
|
+
}
|
|
2755
|
+
// The status read awaited: the chat may have been closed (and its id reopened) meanwhile —
|
|
2756
|
+
// nothing of this message may land on the newcomer (codex on #808 r3, 2).
|
|
2757
|
+
if (askPaths.get(id)?.generation !== lease) {
|
|
2758
|
+
if (turn !== null)
|
|
2759
|
+
chatTurns.abort(id, turn.turnId);
|
|
2760
|
+
return reply.code(409).send({ error: `chat '${id}' was closed while the message was in flight; nothing was sent` });
|
|
2761
|
+
}
|
|
2762
|
+
const stepId = askPaths.nextStep(id);
|
|
2763
|
+
const pending = await considerations.pendingPreface(id);
|
|
2764
|
+
await adapter.proposePlan(path.runId, { steps: [answerStep(stepId, withPreface(pending.preface))] }, turnId);
|
|
2765
|
+
await adapter.confirmGate(path.runId, true);
|
|
2766
|
+
askPaths.release(id, lease);
|
|
2767
|
+
if (!askPaths.continued(id, lease, stepId)) {
|
|
2768
|
+
// Closed between the proposal and here: the DELETE cancelled the run this was accepted on.
|
|
2769
|
+
return reply.code(409).send({ error: `chat '${id}' was closed while the message was in flight; its path was cancelled` });
|
|
2770
|
+
}
|
|
2771
|
+
pending.commit();
|
|
2772
|
+
persistAccepted(pending.preface, () => undefined);
|
|
2773
|
+
return reply.code(202).send({ seats: voice, turnId, runId: path.runId, stepId, ...singleSeat202 });
|
|
2697
2774
|
}
|
|
2698
2775
|
catch (err) {
|
|
2699
|
-
|
|
2776
|
+
askPaths.release(id, lease);
|
|
2700
2777
|
if (turn !== null)
|
|
2701
2778
|
chatTurns.abort(id, turn.turnId);
|
|
2702
|
-
|
|
2703
|
-
return reply.code(/no warm seats/.test(msg) ? 409 : 400).send({ error: msg });
|
|
2779
|
+
return reply.code(400).send({ error: message(err) });
|
|
2704
2780
|
}
|
|
2705
2781
|
});
|
|
2706
|
-
// F-W1-005 (wave-1 P6): re-seat NAMED seats on a LIVE chat — the retry lever for a seat the open
|
|
2707
|
-
// refused (or whose session died). Re-runs the engine's per-seat `chat_ensure` through `chatOpen`
|
|
2708
|
-
// with the IDENTICAL scope recorded at open (a different scope would evict every warm seat —
|
|
2709
|
-
// Copilot, #426; the index retains what the engine was handed). Explicit seats bypass the daemon's
|
|
2710
|
-
// default pre-filter exactly as `clis` on `POST /chats` does: the engine refuses per seat and its
|
|
2711
|
-
// reason rides the outcome, then `refused[]` and a `chatSeatRefused` frame — the same shapes the
|
|
2712
|
-
// 201 carries, so the thread and the picker read one story. Never a new chat, never a new pool key.
|
|
2713
2782
|
const ChatSeatsSchema = z.object({
|
|
2714
2783
|
clis: z.array(z.string().min(1)).min(1).max(8),
|
|
2715
2784
|
}).strict();
|
|
@@ -2732,7 +2801,10 @@ runtime = {}) {
|
|
|
2732
2801
|
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
2733
2802
|
}
|
|
2734
2803
|
const engine = chatScopes.engineOf(id);
|
|
2735
|
-
|
|
2804
|
+
// ASK-C1: a chat is open here when it has a path record (every open leaves one, scoped or
|
|
2805
|
+
// not) — the engine scope alone is the pool-era test.
|
|
2806
|
+
const launched = askPaths.get(id);
|
|
2807
|
+
if (engine === undefined && launched === undefined) {
|
|
2736
2808
|
return reply.code(404).send({
|
|
2737
2809
|
error: `chat ${id} is not open on this daemon — open a chat first, then re-seat into it`,
|
|
2738
2810
|
});
|
|
@@ -2743,25 +2815,40 @@ runtime = {}) {
|
|
|
2743
2815
|
if (inFlight !== null) {
|
|
2744
2816
|
return reply.code(409).send(turnInFlightBody(id, inFlight, 'No seat was re-warmed.'));
|
|
2745
2817
|
}
|
|
2818
|
+
// ASK-C1: the path's roster is handed to the engine at launch and never updated after —
|
|
2819
|
+
// a seat admitted later would be reported eligible and never reach the run (codex on #808
|
|
2820
|
+
// r2, 6). Re-seat before the first message; after it, a new chat.
|
|
2821
|
+
if (launched?.runId !== undefined) {
|
|
2822
|
+
return reply.code(409).send({
|
|
2823
|
+
error: `chat '${id}' has launched its path (${launched.runId}); its seats are fixed for this ask — open a new chat to seat ${clis.join(', ')}`,
|
|
2824
|
+
chatId: id,
|
|
2825
|
+
runId: launched.runId,
|
|
2826
|
+
});
|
|
2827
|
+
}
|
|
2828
|
+
if (launched?.busy === true) {
|
|
2829
|
+
// The first message is launching with the roster as it stands (codex on #808 r3, 4).
|
|
2830
|
+
return reply.code(409).send({
|
|
2831
|
+
code: 'turn_in_flight',
|
|
2832
|
+
error: `chat '${id}' is launching its path with the seats as they stand — a seat added now would never reach the run. Nothing was re-seated.`,
|
|
2833
|
+
chatId: id,
|
|
2834
|
+
});
|
|
2835
|
+
}
|
|
2746
2836
|
try {
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2837
|
+
// ASK-C1: a re-seat re-admits the named seats that pass admission NOW (a sign-in fixed)
|
|
2838
|
+
// into the path's eligible roster; nothing is warmed and the engine is not called.
|
|
2839
|
+
const scopeNow = chatScopes.get(id);
|
|
2840
|
+
const scopedChat = (scopeNow?.kind ?? 'none') !== 'none' && scopeNow?.kind !== 'system';
|
|
2841
|
+
const reseatKind = scopeNow?.kind === 'repos' && scopeNow.repos.length === 1 ? 'path-bound' : 'path';
|
|
2842
|
+
const standingNow = rosterWithStanding();
|
|
2843
|
+
const seats = clis.map((cliKey) => {
|
|
2844
|
+
const seat = standingNow.find((s) => String(s.key) === cliKey);
|
|
2845
|
+
if (seat === undefined)
|
|
2846
|
+
return { cliKey, ok: false, error: `seat '${cliKey}' is not in the roster` };
|
|
2847
|
+
const admission = chatSeatAdmission(seat, seat.auth ?? 'unknown', scopedChat, reseatKind);
|
|
2848
|
+
return admission.ok ? { cliKey, ok: true } : { cliKey, ok: false, error: admission.reason };
|
|
2750
2849
|
});
|
|
2850
|
+
askPaths.admit(id, seats.filter((s) => s.ok).map((s) => s.cliKey));
|
|
2751
2851
|
const refused = chatScopes.foldSeats(id, seats);
|
|
2752
|
-
const projectId = projects.index.projectOf(id) ?? undefined;
|
|
2753
|
-
for (const s of seats) {
|
|
2754
|
-
if (s.ok)
|
|
2755
|
-
continue;
|
|
2756
|
-
runtime.broadcast?.({
|
|
2757
|
-
type: 'chatSeatRefused',
|
|
2758
|
-
chat: id,
|
|
2759
|
-
cliKey: s.cliKey,
|
|
2760
|
-
reason: s.error ?? 'the engine refused the seat',
|
|
2761
|
-
source: 'engine',
|
|
2762
|
-
...(projectId !== undefined ? { project_id: projectId } : {}),
|
|
2763
|
-
});
|
|
2764
|
-
}
|
|
2765
2852
|
return { chatId: id, seats, refused };
|
|
2766
2853
|
}
|
|
2767
2854
|
catch (err) {
|
|
@@ -2775,7 +2862,24 @@ runtime = {}) {
|
|
|
2775
2862
|
// segment first), but the order reads the way the routes nest.
|
|
2776
2863
|
app.get(`${V}/chats`, { config: { manifest: { responseType: 'ChatListResponse', statusCodes: [200, 400, 501] } } }, async (_req, reply) => {
|
|
2777
2864
|
try {
|
|
2778
|
-
|
|
2865
|
+
const pool = await adapter.chatList().catch((err) => {
|
|
2866
|
+
if (err instanceof ChatUnsupportedError)
|
|
2867
|
+
return [];
|
|
2868
|
+
throw err;
|
|
2869
|
+
});
|
|
2870
|
+
// ASK-C1: an ask path is a chat of this daemon with no engine pool row — listed from the
|
|
2871
|
+
// ask-path index (the pool entries stay until ASK-K4 deletes the pool).
|
|
2872
|
+
const paths = askPaths.list().map((p) => {
|
|
2873
|
+
const engine = chatScopes.engineOf(p.chatId);
|
|
2874
|
+
return {
|
|
2875
|
+
chatId: p.chatId,
|
|
2876
|
+
seats: [...p.eligible],
|
|
2877
|
+
idleSecs: null,
|
|
2878
|
+
...(engine !== undefined ? { cwd: engine.cwd, codeGraphDb: engine.codeGraphDb ?? null, readRoots: [...engine.readRoots] } : {}),
|
|
2879
|
+
};
|
|
2880
|
+
});
|
|
2881
|
+
const seen = new Set(paths.map((p) => p.chatId));
|
|
2882
|
+
return { chats: [...paths, ...pool.filter((c) => !seen.has(c.chatId))] };
|
|
2779
2883
|
}
|
|
2780
2884
|
catch (err) {
|
|
2781
2885
|
// A build that cannot do chat is a capability gap, not a bad request: 501 tells an operator to
|
|
@@ -2811,9 +2915,12 @@ runtime = {}) {
|
|
|
2811
2915
|
// the seats refused at open (F-A45-011), so the studio's admission copy survives a reload.
|
|
2812
2916
|
return {
|
|
2813
2917
|
chatId: id,
|
|
2814
|
-
|
|
2918
|
+
// ASK-C1: the eligible roster is the chat's seats (no warm pool); a chat this daemon
|
|
2919
|
+
// did not open answers `[]`.
|
|
2920
|
+
seats: askPaths.get(id)?.eligible ?? [],
|
|
2815
2921
|
scope: chatScopes.get(id) ?? null,
|
|
2816
2922
|
refused: chatScopes.refusedOf(id) ?? null,
|
|
2923
|
+
...(askPaths.view(id) !== undefined ? { path: askPaths.view(id) } : {}),
|
|
2817
2924
|
// DES-L5 (D-13): the transcript so far, append order — `[]` before the first persisted
|
|
2818
2925
|
// turn; the file goes with the chat on `chatClosed`, so a reclaimed id answers `[]` too.
|
|
2819
2926
|
...(chatTranscripts !== undefined ? { messages: chatTranscripts.read(id) } : {}),
|
|
@@ -2826,17 +2933,49 @@ runtime = {}) {
|
|
|
2826
2933
|
app.delete(`${V}/chats/:id`, async (req, reply) => {
|
|
2827
2934
|
const { id } = req.params;
|
|
2828
2935
|
// The scratch root goes with the chat at once, whether or not the engine still knew it
|
|
2829
|
-
// (crew#502)
|
|
2830
|
-
// close
|
|
2831
|
-
|
|
2936
|
+
// (crew#502). ASK-C1: the daemon owns the close, so the id is HELD — no grace timer — until
|
|
2937
|
+
// this close settles below; a reopen meanwhile is refused and a second DELETE joins this one
|
|
2938
|
+
// (nothing of the closed chat can land on a reuse — codex on #808 r5).
|
|
2939
|
+
const ticket = chatScopes.holdClose(id);
|
|
2940
|
+
if (ticket === null)
|
|
2941
|
+
return { ok: true };
|
|
2832
2942
|
chatTurns.closed(id);
|
|
2943
|
+
// ASK-C1: End cancels the path (`path.ended{status:"cancelled"}`); the engine holds no chat
|
|
2944
|
+
// row for an ask, so the daemon closes the id itself and the transcript follows the chat
|
|
2945
|
+
// through the same `chatClosed` fold a pool chat used (dropped unless a promoted run retains it).
|
|
2946
|
+
const path = askPaths.close(id);
|
|
2833
2947
|
try {
|
|
2834
|
-
|
|
2948
|
+
const projectId = projects.index.projectOf(id) ?? undefined;
|
|
2949
|
+
const frame = {
|
|
2950
|
+
type: 'chatClosed',
|
|
2951
|
+
chat: id,
|
|
2952
|
+
reason: 'closed',
|
|
2953
|
+
...(projectId !== undefined ? { project_id: projectId } : {}),
|
|
2954
|
+
};
|
|
2955
|
+
// The server's fold (scope slot, decisions, considerations, retention-aware transcript
|
|
2956
|
+
// drop) and the /ws fan-out — the same path an engine `chatClosed` took. Folded BEFORE the
|
|
2957
|
+
// awaited cancel: a close that lands after an await can land on a chat that reused the id
|
|
2958
|
+
// meanwhile (codex on #808 r2, 2). AWAITED: the decision recorder settles the chat's open
|
|
2959
|
+
// turns under the chat's own project before the id is free (the slot is parked `closing`
|
|
2960
|
+
// meanwhile, so a reopen waits), and only then is nothing of the closed chat pending
|
|
2961
|
+
// (codex on #808 r4, 1).
|
|
2962
|
+
if (runtime.closeChat !== undefined)
|
|
2963
|
+
await runtime.closeChat(frame, path?.runId, ticket);
|
|
2964
|
+
else {
|
|
2965
|
+
chatScopes.closeHeld(id, ticket);
|
|
2966
|
+
runtime.broadcast?.(frame);
|
|
2967
|
+
}
|
|
2968
|
+
if (path?.runId !== undefined)
|
|
2969
|
+
await adapter.cancelRun(path.runId).catch(() => undefined);
|
|
2835
2970
|
return { ok: true };
|
|
2836
2971
|
}
|
|
2837
2972
|
catch (err) {
|
|
2838
2973
|
return reply.code(400).send({ error: message(err) });
|
|
2839
2974
|
}
|
|
2975
|
+
finally {
|
|
2976
|
+
// Never left held: a fold that threw frees the id here (its own close settles it otherwise).
|
|
2977
|
+
chatScopes.closeHeld(id, ticket);
|
|
2978
|
+
}
|
|
2840
2979
|
});
|
|
2841
2980
|
// A unit's captured transcript. `unitKey` accepts the fully-qualified id (`run-1:survey`),
|
|
2842
2981
|
// the id suffix (`survey`, `u3`), or the ordinal (`u3`, `3`) — see `resolveUnit`.
|