@cydm/happy-elves 0.1.0-beta.504 → 0.1.0-beta.506
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/README.md +2 -2
- package/apps/cli/package.json +1 -1
- package/apps/daemon/dist/direct/server.js +2 -0
- package/apps/daemon/dist/orchestration/crew-queue.js +3 -1
- package/apps/daemon/dist/orchestration/handler.d.ts +4 -0
- package/apps/daemon/dist/orchestration/handler.js +195 -1
- package/apps/daemon/dist/orchestration/queue-state.js +3 -0
- package/apps/daemon/dist/orchestration/store.d.ts +10 -0
- package/apps/daemon/dist/orchestration/store.js +52 -4
- package/apps/daemon/dist/relay/connection.js +7 -1
- package/apps/daemon/dist/relay/send.js +1 -0
- package/apps/daemon/dist/runtime/headless-event-projector.js +8 -6
- package/apps/daemon/dist/session/he-official/common.d.ts +6 -0
- package/apps/daemon/dist/session/prompt.d.ts +10 -1
- package/apps/daemon/dist/session/prompt.js +11 -2
- package/apps/daemon/dist/update-barrier.js +1 -0
- package/apps/daemon/package.json +1 -1
- package/package.json +2 -2
- package/packages/client/dist/client.d.ts +9 -1
- package/packages/client/dist/client.js +47 -2
- package/packages/client/dist/product-materializer.js +2 -0
- package/packages/client/dist/transport.d.ts +5 -0
- package/packages/client/dist/transport.js +7 -0
- package/packages/client/dist/types.d.ts +43 -1
- package/packages/client/dist/workspace-shared-config.d.ts +5 -0
- package/packages/client/dist/workspace-shared-config.js +35 -0
- package/packages/headless-runtime/dist/codex-provider.js +10 -1
- package/packages/shared/dist/protocol-schemas.d.ts +70 -0
- package/packages/shared/dist/protocol-schemas.js +18 -0
- package/packages/shared/dist/protocol-types.d.ts +32 -0
- package/packages/shared/dist/protocol.d.ts +62 -1
- package/packages/shared/dist/protocol.js +53 -1
package/README.md
CHANGED
|
@@ -6,8 +6,8 @@ Public beta CLI for Happy Elves.
|
|
|
6
6
|
|
|
7
7
|
Run once in the existing user account; it detects %USERPROFILE%\.happy-elves\hosted, keeps the current daemon and sessions running, installs the independent updater, and registers per-user startup.
|
|
8
8
|
|
|
9
|
-
npm.cmd install --prefix $HOME\.happy-elves\management-entry --ignore-scripts --no-audit --no-fund --package-lock=false @cydm/happy-elves@0.1.0-beta.
|
|
10
|
-
npm.cmd exec --yes --package @cydm/happy-elves@0.1.0-beta.
|
|
9
|
+
npm.cmd install --prefix $HOME\.happy-elves\management-entry --ignore-scripts --no-audit --no-fund --package-lock=false @cydm/happy-elves@0.1.0-beta.506
|
|
10
|
+
npm.cmd exec --yes --package @cydm/happy-elves@0.1.0-beta.506 -- happy-elves-updater install --state-root $HOME\.happy-elves\hosted --autostart
|
|
11
11
|
|
|
12
12
|
The same updater entrypoint works on macOS after selecting a permanent user prefix.
|
|
13
13
|
|
package/apps/cli/package.json
CHANGED
|
@@ -395,6 +395,8 @@ class DirectState {
|
|
|
395
395
|
return this.machineScoped(message, "machine:dispatch", connection);
|
|
396
396
|
if (message.type === "controller:queue")
|
|
397
397
|
return this.machineScoped(message, "machine:queue", connection);
|
|
398
|
+
if (message.type === "controller:followup")
|
|
399
|
+
return this.machineScoped(message, "machine:followup", connection);
|
|
398
400
|
if (message.type === "controller:crewQueueEnqueue")
|
|
399
401
|
return this.machineScoped(message, "machine:crewQueueEnqueue", connection);
|
|
400
402
|
if (message.type === "controller:crewQueueUpdate")
|
|
@@ -298,7 +298,9 @@ async function completeMaterializedCrewQueueHead(config, queue) {
|
|
|
298
298
|
.find((record) => record.crewSessionId === queue.crewSessionId && (record.crewMachineId ?? queue.crewMachineId) === queue.crewMachineId);
|
|
299
299
|
if (!head)
|
|
300
300
|
return "none";
|
|
301
|
-
|
|
301
|
+
// Follow-ups carry the caller's stable queued turn identity before delivery;
|
|
302
|
+
// recovery must use it to find a runtime turn created just before a crash.
|
|
303
|
+
const turnId = head.turnId ?? (head.kind === "followup" ? head.queuedTurnId : undefined) ?? stableId("turn", head.taskId, head.requestId, "brief");
|
|
302
304
|
const runtimeTurnId = head.runtimeTurnId ?? head.providerTurnId ?? await runtimeTurnIdForCrewTurn(queue.crewSessionId, turnId);
|
|
303
305
|
if (!runtimeTurnId)
|
|
304
306
|
return "none";
|
|
@@ -7,6 +7,9 @@ type DispatchCommand = Extract<MachineCommand, {
|
|
|
7
7
|
type QueueCommand = Extract<MachineCommand, {
|
|
8
8
|
type: "machine:queue";
|
|
9
9
|
}>;
|
|
10
|
+
type FollowUpCommand = Extract<MachineCommand, {
|
|
11
|
+
type: "machine:followup";
|
|
12
|
+
}>;
|
|
10
13
|
export type DispatchListCommand = Extract<DispatchCommand, {
|
|
11
14
|
action: "list";
|
|
12
15
|
}>;
|
|
@@ -17,6 +20,7 @@ export type DispatchListResult = {
|
|
|
17
20
|
};
|
|
18
21
|
export declare function handleDispatchCommand(ws: WebSocket, config: DaemonConfig, command: DispatchCommand): Promise<void>;
|
|
19
22
|
export declare function handleQueueCommand(ws: WebSocket, config: DaemonConfig, command: QueueCommand): Promise<void>;
|
|
23
|
+
export declare function handleFollowUpCommand(ws: WebSocket, config: DaemonConfig, command: FollowUpCommand): Promise<void>;
|
|
20
24
|
export declare function dispatchListResultForCommand(config: DaemonConfig, command: DispatchListCommand): Promise<DispatchListResult>;
|
|
21
25
|
export declare function remoteLoadedSessionInventory(config: DaemonConfig, machineId: string, filters?: {
|
|
22
26
|
cwd?: string;
|
|
@@ -8,7 +8,7 @@ import { claimCommandRequest, sendCommandResponse } from "../relay/send.js";
|
|
|
8
8
|
import { runtimeRead, runtimeSessionCheckpoint, runtimeTurnSummaryForTask } from "../session/runtime-truth.js";
|
|
9
9
|
import { loadSessionProjections } from "../session/store.js";
|
|
10
10
|
import { listArtifacts } from "../artifact/store.js";
|
|
11
|
-
import { ackAttention, completeClaimedQueueAdmission, crewQueueHasBacklog, deadAttention, failClaimedQueueAdmission, findAdmissionByTask, findAdmissionByTaskAndRequest, latestAttentionForTask, listAdmissions, listAttentions, recordAdmission, recordTurnRuntimeBinding, reopenUnresolvedArtifactFeedbackAttentions, retryAttention, retryClaimedQueueAdmission, runtimeTurnIdForCrewTurn, updateAdmission, updatePendingConsumerQueueEnqueue, upsertSubscription, } from "./store.js";
|
|
11
|
+
import { ackAttention, completeClaimedQueueAdmission, crewQueueHasBacklog, deadAttention, failClaimedQueueAdmission, findAdmissionByTask, findAdmissionByTaskAndRequest, latestAttentionForTask, listAdmissions, listAttentions, recordAdmission, recordTurnRuntimeBinding, reopenUnresolvedArtifactFeedbackAttentions, retryAttention, retryClaimedQueueAdmission, runtimeTurnIdForCrewTurn, updateAdmission, updatePendingConsumerQueueEnqueue, cancelQueuedFollowUp, findFollowUpById, listFollowUpsForSession, upsertSubscription, } from "./store.js";
|
|
12
12
|
import { catchUpSubscriptionEvents } from "./watcher.js";
|
|
13
13
|
export async function handleDispatchCommand(ws, config, command) {
|
|
14
14
|
// Status/list are read projections, not mutation retries. A caller may use
|
|
@@ -466,6 +466,161 @@ export async function handleQueueCommand(ws, config, command) {
|
|
|
466
466
|
});
|
|
467
467
|
}
|
|
468
468
|
}
|
|
469
|
+
export async function handleFollowUpCommand(ws, config, command) {
|
|
470
|
+
// Follow-up request IDs are scoped to this command contract. Never let the
|
|
471
|
+
// generic 24h response cache bypass target/session/payload validation (or
|
|
472
|
+
// replay a response from another command using the same correlation id).
|
|
473
|
+
completedCommandResponses.delete(command.requestId);
|
|
474
|
+
if (command.machineId !== config.machineId) {
|
|
475
|
+
await sendCommandResponse(ws, { type: "machine:error", requestId: command.requestId, code: "WRONG_MACHINE", message: `Follow-up command targeted ${command.machineId}, not ${config.machineId}` });
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const reply = async (result) => {
|
|
479
|
+
await sendCommandResponse(ws, { type: "machine:followupResult", requestId: command.requestId, machineId: config.machineId, result });
|
|
480
|
+
};
|
|
481
|
+
if (command.action === "list" || command.action === "cancel") {
|
|
482
|
+
const session = await ensureSessionLoaded(command.sessionId);
|
|
483
|
+
if (!session) {
|
|
484
|
+
await sendCommandResponse(ws, {
|
|
485
|
+
type: "machine:error",
|
|
486
|
+
requestId: command.requestId,
|
|
487
|
+
code: "SESSION_NOT_FOUND",
|
|
488
|
+
capability: `followup.${command.action}`,
|
|
489
|
+
message: `Session ${command.sessionId} is not loaded on this daemon`,
|
|
490
|
+
});
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (command.action === "list" || command.action === "cancel") {
|
|
495
|
+
if (!claimCommandRequest(ws, command.requestId))
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (command.action === "list") {
|
|
499
|
+
const records = await listFollowUpsForSession(command.sessionId);
|
|
500
|
+
await reply({ action: "list", requestId: command.requestId, sessionId: command.sessionId, records: await Promise.all(records.map((record) => followUpQueueRecord(config, record))) });
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (command.action === "cancel") {
|
|
504
|
+
const outcome = await cancelQueuedFollowUp(command.sessionId, command.followUpId);
|
|
505
|
+
if (outcome.status === "missing") {
|
|
506
|
+
await reply({ action: "cancel", requestId: command.requestId, sessionId: command.sessionId, followUpId: command.followUpId, cancelled: false, conflict: "Follow-up was not found" });
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (outcome.status === "conflict") {
|
|
510
|
+
await reply({ action: "cancel", requestId: command.requestId, sessionId: command.sessionId, followUpId: command.followUpId, cancelled: false, conflict: "Follow-up is already being delivered", ...(outcome.record ? { followUp: await followUpQueueRecord(config, outcome.record) } : {}) });
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
await reply({ action: "cancel", requestId: command.requestId, sessionId: command.sessionId, followUpId: command.followUpId, cancelled: true, ...(outcome.record ? { followUp: await followUpQueueRecord(config, outcome.record) } : {}) });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const session = await ensureSessionLoaded(command.sessionId);
|
|
517
|
+
if (!session) {
|
|
518
|
+
await sendCommandResponse(ws, { type: "machine:error", requestId: command.requestId, code: "SESSION_NOT_FOUND", capability: "followup.enqueue", message: `Session ${command.sessionId} is not loaded on this daemon` });
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const existing = await findFollowUpById(command.sessionId, command.followUpId);
|
|
522
|
+
if (existing) {
|
|
523
|
+
const samePayload = existing.encryptedBrief
|
|
524
|
+
? await sameFollowUpPayload(config, existing.encryptedBrief, command.encryptedPrompt)
|
|
525
|
+
: false;
|
|
526
|
+
const existingTurnId = existing.queuedTurnId ?? existing.turnId;
|
|
527
|
+
// Optional follow-up fields have protocol defaults. A caller replaying a
|
|
528
|
+
// request may omit them after the first attempt; compare the normalized
|
|
529
|
+
// execution instead of treating an omitted default as a conflict. A
|
|
530
|
+
// supplied turn id remains an explicit identity check, while an omitted
|
|
531
|
+
// turn id is intentionally compatible with the owner-assigned id.
|
|
532
|
+
const sameExecution = existing.requestId === command.requestId &&
|
|
533
|
+
samePayload &&
|
|
534
|
+
(command.turnId === undefined || existingTurnId === command.turnId) &&
|
|
535
|
+
(existing.permissionMode ?? "approve-reads") === crewTurnPermissionMode(command.permissionMode) &&
|
|
536
|
+
(existing.model ?? undefined) === (command.model ?? undefined) &&
|
|
537
|
+
(existing.thinkingLevel ?? undefined) === (command.thinkingLevel ?? undefined);
|
|
538
|
+
if (!sameExecution) {
|
|
539
|
+
await sendCommandResponse(ws, { type: "machine:error", requestId: command.requestId, code: "FOLLOWUP_IDEMPOTENCY_CONFLICT", capability: "followup.enqueue", message: `Follow-up ${command.followUpId} already exists with different content or request id` });
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (!claimCommandRequest(ws, command.requestId))
|
|
543
|
+
return;
|
|
544
|
+
const { signalCrewQueue } = await import("./crew-queue.js");
|
|
545
|
+
void signalCrewQueue(config, { crewMachineId: config.machineId, crewSessionId: command.sessionId, reason: "admission" }).catch(() => undefined);
|
|
546
|
+
await reply({ action: "enqueue", requestId: command.requestId, followUp: await followUpQueueRecord(config, existing), replayed: true });
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (!claimCommandRequest(ws, command.requestId))
|
|
550
|
+
return;
|
|
551
|
+
const now = new Date().toISOString();
|
|
552
|
+
const record = await recordAdmission({
|
|
553
|
+
kind: "followup",
|
|
554
|
+
followUpId: command.followUpId,
|
|
555
|
+
followUpStatus: "queued",
|
|
556
|
+
...(command.turnId ? { queuedTurnId: command.turnId } : {}),
|
|
557
|
+
taskId: stableId("followup", command.sessionId, command.followUpId),
|
|
558
|
+
requestId: command.requestId,
|
|
559
|
+
consumerMachineId: config.machineId,
|
|
560
|
+
crewMachineId: config.machineId,
|
|
561
|
+
consumerSessionId: command.sessionId,
|
|
562
|
+
crewSessionId: command.sessionId,
|
|
563
|
+
agent: session.agent,
|
|
564
|
+
cwd: session.cwd,
|
|
565
|
+
briefVersion: 1,
|
|
566
|
+
admission: "accepted",
|
|
567
|
+
dispatch: "queued",
|
|
568
|
+
routeDecision: { kind: "reuse", crewSessionId: command.sessionId, ifBusy: "queue" },
|
|
569
|
+
queuedAt: now,
|
|
570
|
+
queueAuthority: "owner",
|
|
571
|
+
encryptedBrief: command.encryptedPrompt,
|
|
572
|
+
permissionMode: crewTurnPermissionMode(command.permissionMode),
|
|
573
|
+
model: command.model,
|
|
574
|
+
thinkingLevel: command.thinkingLevel,
|
|
575
|
+
subscribe: false,
|
|
576
|
+
queueAttempts: 0,
|
|
577
|
+
createdAt: now,
|
|
578
|
+
updatedAt: now,
|
|
579
|
+
});
|
|
580
|
+
if (record.requestId !== command.requestId) {
|
|
581
|
+
await sendCommandResponse(ws, { type: "machine:error", requestId: command.requestId, code: "FOLLOWUP_IDEMPOTENCY_CONFLICT", capability: "followup.enqueue", message: `Follow-up ${command.followUpId} already exists with a different request id` });
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
const { signalCrewQueue } = await import("./crew-queue.js");
|
|
585
|
+
void signalCrewQueue(config, { crewMachineId: config.machineId, crewSessionId: command.sessionId, reason: "admission" }).catch(() => undefined);
|
|
586
|
+
await reply({ action: "enqueue", requestId: command.requestId, followUp: await followUpQueueRecord(config, record) });
|
|
587
|
+
}
|
|
588
|
+
async function sameFollowUpPayload(config, left, right) {
|
|
589
|
+
try {
|
|
590
|
+
const [leftPayload, rightPayload] = await Promise.all([
|
|
591
|
+
decryptJson(config.accountSecret, left),
|
|
592
|
+
decryptJson(config.accountSecret, right),
|
|
593
|
+
]);
|
|
594
|
+
return JSON.stringify(leftPayload) === JSON.stringify(rightPayload);
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
async function followUpQueueRecord(config, record) {
|
|
601
|
+
let text;
|
|
602
|
+
if (record.encryptedBrief) {
|
|
603
|
+
try {
|
|
604
|
+
const payload = await decryptJson(config.accountSecret, record.encryptedBrief);
|
|
605
|
+
if (payload.type === "user_prompt")
|
|
606
|
+
text = payload.text;
|
|
607
|
+
}
|
|
608
|
+
catch { /* encrypted content is optional in projection */ }
|
|
609
|
+
}
|
|
610
|
+
return {
|
|
611
|
+
id: record.followUpId ?? record.taskId,
|
|
612
|
+
requestId: record.requestId,
|
|
613
|
+
sessionId: record.consumerSessionId,
|
|
614
|
+
machineId: record.crewMachineId ?? config.machineId,
|
|
615
|
+
status: record.followUpStatus ?? (record.dispatch === "delivered" ? "delivered" : record.dispatch === "failed" ? "failed" : "queued"),
|
|
616
|
+
...(record.turnId ? { turnId: record.turnId } : {}),
|
|
617
|
+
...(text !== undefined ? { text } : {}),
|
|
618
|
+
createdAt: record.createdAt,
|
|
619
|
+
...(record.dispatch === "delivered" ? { deliveredAt: record.updatedAt } : {}),
|
|
620
|
+
...(record.errorCode ? { errorCode: record.errorCode } : {}),
|
|
621
|
+
...(record.error ? { error: record.error } : {}),
|
|
622
|
+
};
|
|
623
|
+
}
|
|
469
624
|
export async function dispatchListResultForCommand(config, command) {
|
|
470
625
|
const admissions = await listAdmissions({ consumerSessionId: command.consumerSessionId });
|
|
471
626
|
const records = await Promise.all(admissions.map((admission) => dispatchTaskProjection(config, admission)));
|
|
@@ -772,6 +927,8 @@ export async function deliverQueuedCrewAdmission(config, admission) {
|
|
|
772
927
|
const claimId = admission.queuedClaim?.claimId;
|
|
773
928
|
if (!claimId)
|
|
774
929
|
return admission;
|
|
930
|
+
if (admission.kind === "followup")
|
|
931
|
+
return await deliverQueuedFollowUpAdmission(config, admission);
|
|
775
932
|
if (!crewSessionId || !admission.encryptedBrief) {
|
|
776
933
|
const errorCode = "QUEUED_DISPATCH_INVALID";
|
|
777
934
|
const error = !crewSessionId ? "Queued admission is missing crewSessionId" : "Queued admission is missing encrypted brief";
|
|
@@ -863,6 +1020,43 @@ export async function deliverQueuedCrewAdmission(config, admission) {
|
|
|
863
1020
|
}
|
|
864
1021
|
return delivered;
|
|
865
1022
|
}
|
|
1023
|
+
async function deliverQueuedFollowUpAdmission(config, admission) {
|
|
1024
|
+
const claimId = admission.queuedClaim?.claimId;
|
|
1025
|
+
const sessionId = admission.crewSessionId;
|
|
1026
|
+
if (!claimId || !sessionId || !admission.encryptedBrief)
|
|
1027
|
+
return admission;
|
|
1028
|
+
const state = await crewSessionRuntimeState(config, config.machineId, sessionId);
|
|
1029
|
+
if (state.availability === "busy") {
|
|
1030
|
+
return await retryClaimedQueueAdmission({ taskId: admission.taskId, requestId: admission.requestId, claimId, error: "target Crew is still busy", queueReason: "target-crew-busy", retryAfterMs: 30_000 }) ?? admission;
|
|
1031
|
+
}
|
|
1032
|
+
if (state.availability === "unavailable") {
|
|
1033
|
+
if (state.retryable)
|
|
1034
|
+
return await retryClaimedQueueAdmission({ taskId: admission.taskId, requestId: admission.requestId, claimId, error: state.error ?? "Crew unavailable", queueReason: "target-crew-unavailable", retryAfterMs: queueRetryDelayMs(admission.queueAttempts ?? 1) }) ?? admission;
|
|
1035
|
+
return (await failClaimedQueueAdmission({ taskId: admission.taskId, requestId: admission.requestId, claimId, errorCode: state.errorCode ?? "CREW_SESSION_UNAVAILABLE", error: state.error ?? "Crew session unavailable" })).admission ?? admission;
|
|
1036
|
+
}
|
|
1037
|
+
const turnId = admission.turnId ?? admission.queuedTurnId ?? stableId("followup-turn", sessionId, admission.followUpId ?? admission.taskId);
|
|
1038
|
+
const promptRequestId = stableId("followup-prompt", admission.requestId, sessionId);
|
|
1039
|
+
try {
|
|
1040
|
+
await admitMachinePrompt(config, {
|
|
1041
|
+
requestId: promptRequestId,
|
|
1042
|
+
sessionId,
|
|
1043
|
+
turnId,
|
|
1044
|
+
encryptedPrompt: admission.encryptedBrief,
|
|
1045
|
+
permissionMode: admission.permissionMode ?? "approve-reads",
|
|
1046
|
+
...(admission.model ? { model: admission.model } : {}),
|
|
1047
|
+
...(admission.thinkingLevel ? { thinkingLevel: admission.thinkingLevel } : {}),
|
|
1048
|
+
});
|
|
1049
|
+
return await completeClaimedQueueAdmission({ taskId: admission.taskId, requestId: admission.requestId, claimId, turnId }) ?? admission;
|
|
1050
|
+
}
|
|
1051
|
+
catch (error) {
|
|
1052
|
+
const code = dispatchErrorCode(error);
|
|
1053
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1054
|
+
if (queueDeliveryErrorDisposition(code) === "permanent") {
|
|
1055
|
+
return (await failClaimedQueueAdmission({ taskId: admission.taskId, requestId: admission.requestId, claimId, errorCode: code, error: message })).admission ?? admission;
|
|
1056
|
+
}
|
|
1057
|
+
return await retryClaimedQueueAdmission({ taskId: admission.taskId, requestId: admission.requestId, claimId, error: message, queueReason: "delivery-retry", retryAfterMs: queueRetryDelayMs(admission.queueAttempts ?? 1) }) ?? admission;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
866
1060
|
async function deliverDispatch(config, command, admission, options = {}) {
|
|
867
1061
|
const crewSessionId = admission.crewSessionId ?? stableId("ses", admission.taskId, admission.requestId, "crew");
|
|
868
1062
|
const turnId = admission.turnId ?? stableId("turn", admission.taskId, admission.requestId, "brief");
|
|
@@ -125,6 +125,7 @@ export function retryClaimedQueueAdmissionRecord(record, input) {
|
|
|
125
125
|
return {
|
|
126
126
|
...record,
|
|
127
127
|
dispatch: "queued",
|
|
128
|
+
...(record.kind === "followup" ? { followUpStatus: "queued" } : {}),
|
|
128
129
|
turnId: undefined,
|
|
129
130
|
subscriptionCursor: undefined,
|
|
130
131
|
runtimeTurnId: undefined,
|
|
@@ -144,6 +145,7 @@ export function failClaimedQueueAdmissionRecord(record, input) {
|
|
|
144
145
|
return {
|
|
145
146
|
...record,
|
|
146
147
|
dispatch: "failed",
|
|
148
|
+
...(record.kind === "followup" ? { followUpStatus: "failed" } : {}),
|
|
147
149
|
queuedClaim: undefined,
|
|
148
150
|
nextAttemptAt: undefined,
|
|
149
151
|
lastQueueError: input.error,
|
|
@@ -156,6 +158,7 @@ function completeQueuedAdmissionRecord(record, input) {
|
|
|
156
158
|
return {
|
|
157
159
|
...record,
|
|
158
160
|
dispatch: "delivered",
|
|
161
|
+
...(record.kind === "followup" ? { followUpStatus: "delivered" } : {}),
|
|
159
162
|
turnId: input.turnId,
|
|
160
163
|
subscriptionCursor: input.subscriptionCursor,
|
|
161
164
|
runtimeTurnId: input.runtimeTurnId,
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { DispatchAdmissionRecord, EncryptedEnvelope, PermissionMode, TaskAttentionEventType, TaskAttentionPayloadRef, TaskAttentionRecord, TaskAttentionStatus } from "../../../../packages/shared/dist/index.js";
|
|
2
2
|
export type StoredDispatchAdmissionRecord = DispatchAdmissionRecord & {
|
|
3
|
+
kind?: "dispatch" | "followup";
|
|
4
|
+
followUpId?: string;
|
|
5
|
+
followUpStatus?: "queued" | "delivered" | "failed" | "cancelled";
|
|
6
|
+
queuedTurnId?: string;
|
|
3
7
|
dispatchRequestId?: string;
|
|
4
8
|
queueAuthority?: "owner" | "consumer";
|
|
5
9
|
encryptedBrief?: EncryptedEnvelope;
|
|
@@ -131,6 +135,12 @@ export declare function readOrchestrationStore(): Promise<OrchestrationStore>;
|
|
|
131
135
|
export declare function updateOrchestrationStore(update: (store: OrchestrationStore) => OrchestrationStore | Promise<OrchestrationStore>): Promise<OrchestrationStore>;
|
|
132
136
|
export declare function findAdmissionByTaskAndRequest(taskId: string, requestId: string): Promise<StoredDispatchAdmissionRecord | undefined>;
|
|
133
137
|
export declare function findAdmissionByTask(taskId: string): Promise<StoredDispatchAdmissionRecord | undefined>;
|
|
138
|
+
export declare function findFollowUpById(sessionId: string, followUpId: string): Promise<StoredDispatchAdmissionRecord | undefined>;
|
|
139
|
+
export declare function listFollowUpsForSession(sessionId: string): Promise<StoredDispatchAdmissionRecord[]>;
|
|
140
|
+
export declare function cancelQueuedFollowUp(sessionId: string, followUpId: string): Promise<{
|
|
141
|
+
status: "cancelled" | "missing" | "conflict";
|
|
142
|
+
record?: StoredDispatchAdmissionRecord;
|
|
143
|
+
}>;
|
|
134
144
|
export declare function findAdmissionByCrewTurn(crewSessionId: string, turnId: string): Promise<StoredDispatchAdmissionRecord | undefined>;
|
|
135
145
|
export declare function runtimeTurnIdForCrewTurn(crewSessionId: string, turnId: string | undefined): Promise<string | undefined>;
|
|
136
146
|
export declare function listAdmissions(input?: {
|
|
@@ -29,6 +29,41 @@ export async function findAdmissionByTask(taskId) {
|
|
|
29
29
|
const store = await readOrchestrationStore();
|
|
30
30
|
return latestAdmission(store.admissions.filter((record) => record.taskId === taskId));
|
|
31
31
|
}
|
|
32
|
+
export async function findFollowUpById(sessionId, followUpId) {
|
|
33
|
+
const store = await readOrchestrationStore();
|
|
34
|
+
return store.admissions.find((record) => record.kind === "followup" && record.consumerSessionId === sessionId && record.followUpId === followUpId);
|
|
35
|
+
}
|
|
36
|
+
export async function listFollowUpsForSession(sessionId) {
|
|
37
|
+
const store = await readOrchestrationStore();
|
|
38
|
+
return store.admissions
|
|
39
|
+
// Keep delivered records queryable until transcript history has caught up.
|
|
40
|
+
// The Web projection removes them after the exact user_prompt echo, but a
|
|
41
|
+
// closed/reloaded page must still be able to reconcile the durable result.
|
|
42
|
+
.filter((record) => record.kind === "followup" && record.consumerSessionId === sessionId && (record.dispatch === "queued" ||
|
|
43
|
+
record.dispatch === "delivered" ||
|
|
44
|
+
record.followUpStatus === "failed" ||
|
|
45
|
+
record.followUpStatus === "cancelled"))
|
|
46
|
+
.sort((left, right) => (left.queueOrdinal ?? Number.MAX_SAFE_INTEGER) - (right.queueOrdinal ?? Number.MAX_SAFE_INTEGER) || left.createdAt.localeCompare(right.createdAt));
|
|
47
|
+
}
|
|
48
|
+
export async function cancelQueuedFollowUp(sessionId, followUpId) {
|
|
49
|
+
let result = { status: "missing" };
|
|
50
|
+
await updateOrchestrationStore((current) => {
|
|
51
|
+
const index = current.admissions.findIndex((record) => record.kind === "followup" && record.consumerSessionId === sessionId && record.followUpId === followUpId);
|
|
52
|
+
if (index < 0)
|
|
53
|
+
return current;
|
|
54
|
+
const previous = current.admissions[index];
|
|
55
|
+
if (previous.dispatch !== "queued" || previous.queuedClaim) {
|
|
56
|
+
result = { status: "conflict", record: previous };
|
|
57
|
+
return current;
|
|
58
|
+
}
|
|
59
|
+
const now = new Date().toISOString();
|
|
60
|
+
const next = { ...previous, dispatch: "failed", followUpStatus: "cancelled", errorCode: "FOLLOWUP_CANCELLED", error: "Follow-up cancelled before delivery", updatedAt: now };
|
|
61
|
+
current.admissions[index] = next;
|
|
62
|
+
result = { status: "cancelled", record: next };
|
|
63
|
+
return current;
|
|
64
|
+
});
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
32
67
|
export async function findAdmissionByCrewTurn(crewSessionId, turnId) {
|
|
33
68
|
const store = await readOrchestrationStore();
|
|
34
69
|
return latestAdmission(store.admissions.filter((record) => record.crewSessionId === crewSessionId && record.turnId === turnId));
|
|
@@ -424,13 +459,17 @@ export async function ownerCrewQueuePromptGate(input) {
|
|
|
424
459
|
.find((record) => admissionMatchesCrew(record, input));
|
|
425
460
|
if (!head)
|
|
426
461
|
return { allowed: true };
|
|
427
|
-
const expectedPromptRequestId =
|
|
462
|
+
const expectedPromptRequestId = head.kind === "followup"
|
|
463
|
+
? stableId("followup-prompt", head.requestId, input.crewSessionId)
|
|
464
|
+
: stableId("req", head.taskId, head.requestId, "prompt");
|
|
428
465
|
const leaseUntil = head.queuedClaim?.leaseUntil ? Date.parse(head.queuedClaim.leaseUntil) : NaN;
|
|
429
466
|
const validClaim = Boolean(head.queuedClaim?.claimId) && Number.isFinite(leaseUntil) && leaseUntil > now.getTime();
|
|
430
467
|
if (validClaim && input.promptRequestId === expectedPromptRequestId)
|
|
431
468
|
return { allowed: true };
|
|
432
469
|
if (input.promptRequestId === expectedPromptRequestId) {
|
|
433
|
-
const expectedTurnId =
|
|
470
|
+
const expectedTurnId = head.kind === "followup"
|
|
471
|
+
? stableId("followup-turn", input.crewSessionId, head.followUpId ?? head.taskId)
|
|
472
|
+
: stableId("turn", head.taskId, head.requestId, "brief");
|
|
434
473
|
if (await runtimeTurnIdForCrewTurn(input.crewSessionId, expectedTurnId))
|
|
435
474
|
return { allowed: true };
|
|
436
475
|
}
|
|
@@ -569,6 +608,7 @@ export async function failQueuedCrewBacklog(input) {
|
|
|
569
608
|
const admission = withOwnerQueueRevision(current, {
|
|
570
609
|
...previous,
|
|
571
610
|
dispatch: "failed",
|
|
611
|
+
...(previous.kind === "followup" ? { followUpStatus: "failed" } : {}),
|
|
572
612
|
queuedClaim: undefined,
|
|
573
613
|
nextAttemptAt: undefined,
|
|
574
614
|
lastQueueError: input.error,
|
|
@@ -612,8 +652,16 @@ async function updateClaimedQueueAdmission(input, transition) {
|
|
|
612
652
|
return result;
|
|
613
653
|
}
|
|
614
654
|
export async function recordAdmission(record) {
|
|
655
|
+
let conflictRecord;
|
|
615
656
|
const store = await updateOrchestrationStore((current) => {
|
|
616
|
-
|
|
657
|
+
let index = current.admissions.findIndex((item) => item.taskId === record.taskId && item.requestId === record.requestId);
|
|
658
|
+
if (index < 0 && record.kind === "followup" && record.followUpId) {
|
|
659
|
+
index = current.admissions.findIndex((item) => item.kind === "followup" && item.followUpId === record.followUpId && item.consumerSessionId === record.consumerSessionId);
|
|
660
|
+
if (index >= 0) {
|
|
661
|
+
conflictRecord = current.admissions[index];
|
|
662
|
+
return current;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
617
665
|
if (index >= 0) {
|
|
618
666
|
const next = normalizeQueuedStoreFields(current, { ...current.admissions[index], ...record, updatedAt: record.updatedAt }, false);
|
|
619
667
|
current.admissions[index] = next;
|
|
@@ -626,7 +674,7 @@ export async function recordAdmission(record) {
|
|
|
626
674
|
}
|
|
627
675
|
return current;
|
|
628
676
|
});
|
|
629
|
-
return store.admissions.find((item) => item.taskId === record.taskId && item.requestId === record.requestId) ?? record;
|
|
677
|
+
return conflictRecord ?? store.admissions.find((item) => item.taskId === record.taskId && item.requestId === record.requestId) ?? record;
|
|
630
678
|
}
|
|
631
679
|
export async function updateAdmission(taskId, requestId, patch) {
|
|
632
680
|
const updatedAt = new Date().toISOString();
|
|
@@ -4,7 +4,7 @@ import { drainPendingArtifactFeedbackAttentions, handleArtifactCommand } from ".
|
|
|
4
4
|
import { handleGatewayCommand } from "../gateway/handler.js";
|
|
5
5
|
import { tickLoops } from "../loop/runner.js";
|
|
6
6
|
import { handleMemoryCommand } from "../memory/handler.js";
|
|
7
|
-
import { handleDispatchCommand, handleQueueCommand } from "../orchestration/handler.js";
|
|
7
|
+
import { handleDispatchCommand, handleFollowUpCommand, handleQueueCommand } from "../orchestration/handler.js";
|
|
8
8
|
import { handleCrewQueueEnqueueCommand, handleCrewQueueUpdateCommand, recoverCrewQueues } from "../orchestration/crew-queue.js";
|
|
9
9
|
import { tickOrchestration } from "../orchestration/runner.js";
|
|
10
10
|
import { handleScheduleCommand } from "../schedule/handler.js";
|
|
@@ -160,6 +160,10 @@ export async function handleServerMessage(ws, config, message) {
|
|
|
160
160
|
await handleQueueCommand(ws, config, message);
|
|
161
161
|
return;
|
|
162
162
|
}
|
|
163
|
+
if (message.type === "machine:followup") {
|
|
164
|
+
await handleFollowUpCommand(ws, config, message);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
163
167
|
if (message.type === "machine:crewQueueEnqueue") {
|
|
164
168
|
await handleCrewQueueEnqueueCommand(ws, config, message);
|
|
165
169
|
return;
|
|
@@ -491,6 +495,8 @@ async function machineCommandFromController(ws, config, message, senderNodeId) {
|
|
|
491
495
|
return { ...message, type: "machine:dispatch" };
|
|
492
496
|
if (message.type === "controller:queue")
|
|
493
497
|
return { ...message, type: "machine:queue" };
|
|
498
|
+
if (message.type === "controller:followup")
|
|
499
|
+
return { ...message, type: "machine:followup" };
|
|
494
500
|
if (message.type === "controller:crewQueueEnqueue") {
|
|
495
501
|
if (senderNodeId !== message.consumerMachineId) {
|
|
496
502
|
await sendCommandResponse(ws, {
|
|
@@ -900,18 +900,20 @@ export function historicalEventsFromThread(items, turns, limit, runtimeEvents =
|
|
|
900
900
|
if (event)
|
|
901
901
|
addEvent(event);
|
|
902
902
|
}
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
903
|
+
// The canonical item projection is authoritative for text, including user
|
|
904
|
+
// prompts. Do not add user-message snapshots back from the append-only log:
|
|
905
|
+
// a native-history reconciliation can deliberately remove stale HE item
|
|
906
|
+
// identities, and replaying those raw events here would resurrect them in
|
|
907
|
+
// the rebuilt transcript. Forward/anchored reads still use
|
|
908
|
+
// historicalEventsFromRuntimeEvents, where the append-only log is the
|
|
909
|
+
// authority until a canonical rebuild is requested.
|
|
908
910
|
// Server requests and provider/HE tool calls are not guaranteed to be
|
|
909
911
|
// represented by canonical ThreadItems. Preserve only those non-text
|
|
910
912
|
// runtime events which are absent from the canonical identity set. Text
|
|
911
913
|
// snapshots remain canonical-only: merging them would resurrect the raw
|
|
912
914
|
// assistant history that the rebuild deliberately replaces.
|
|
913
915
|
for (const event of historicalEventsFromRuntimeEvents(runtimeEvents)) {
|
|
914
|
-
if (event.payload.type === "text_delta" || event.payload.type === "done")
|
|
916
|
+
if (event.payload.type === "user_prompt" || event.payload.type === "text_delta" || event.payload.type === "done")
|
|
915
917
|
continue;
|
|
916
918
|
if (event.payload.type === "tool_call" && runtimeEventMatchesCanonicalItem(event, canonicalToolItemIds, canonicalToolCallIds))
|
|
917
919
|
continue;
|
|
@@ -264,6 +264,12 @@ export declare function messageResult(message: MachineClientMessage | undefined,
|
|
|
264
264
|
requestId: string;
|
|
265
265
|
machineId: string;
|
|
266
266
|
result: import("../../../../../packages/shared/dist/index.js").QueueCommandResult;
|
|
267
|
+
} | {
|
|
268
|
+
type: "machine:followupResult";
|
|
269
|
+
requestId: string;
|
|
270
|
+
machineId: string;
|
|
271
|
+
messageId?: string;
|
|
272
|
+
result: import("../../../../../packages/shared/dist/index.js").FollowUpQueueResult;
|
|
267
273
|
} | {
|
|
268
274
|
type: "machine:artifactResult";
|
|
269
275
|
requestId: string;
|
|
@@ -4,6 +4,14 @@ type PromptCommand = Extract<MachineCommand, {
|
|
|
4
4
|
type: "machine:prompt";
|
|
5
5
|
}>;
|
|
6
6
|
type PromptExecutionPhase = "decrypt-prompt" | "prepare-attachments" | "decrypt-memory-options" | "prepare-memory-context" | "emit-submitted" | "emit-memory-status" | "prepare-first-agent-context" | "start-turn" | "attach-turn" | "persist-runtime-start" | "audit-turn-start" | "stream-turn" | "await-turn-result" | "write-memory" | "refresh-runtime-metadata" | "emit-terminal" | "refresh-authoritative-transcript" | "mark-terminal-pending" | "send-turn-done" | "mark-terminal-enqueued" | "terminal-hooks";
|
|
7
|
+
type PromptExecutionBoundaries = {
|
|
8
|
+
daemonAcceptedAt: number;
|
|
9
|
+
providerStartAt?: number;
|
|
10
|
+
providerAcceptedAt?: number;
|
|
11
|
+
providerTurnId?: string;
|
|
12
|
+
providerTerminalAt?: number;
|
|
13
|
+
terminalEventAt?: number;
|
|
14
|
+
};
|
|
7
15
|
export declare function handlePrompt(ws: WebSocket, config: DaemonConfig, command: MachineCommand): Promise<void>;
|
|
8
16
|
export declare function handleSteerTurn(ws: WebSocket, config: DaemonConfig, command: MachineCommand): Promise<void>;
|
|
9
17
|
export declare function handleOptionResponse(ws: WebSocket, config: DaemonConfig, command: MachineCommand): Promise<void>;
|
|
@@ -14,13 +22,14 @@ export declare function promptFailureResponse(command: PromptCommand, phase: Pro
|
|
|
14
22
|
code: string;
|
|
15
23
|
message: string;
|
|
16
24
|
};
|
|
17
|
-
export declare function promptFailureAuditEvent(config: Pick<DaemonConfig, "machineId">, command: PromptCommand, phase: PromptExecutionPhase, error: unknown): {
|
|
25
|
+
export declare function promptFailureAuditEvent(config: Pick<DaemonConfig, "machineId">, command: PromptCommand, phase: PromptExecutionPhase, error: unknown, boundaries?: PromptExecutionBoundaries): {
|
|
18
26
|
sessionId: string;
|
|
19
27
|
machineId: string;
|
|
20
28
|
actor: "system";
|
|
21
29
|
action: string;
|
|
22
30
|
summary: string;
|
|
23
31
|
evidence: {
|
|
32
|
+
boundaries?: PromptExecutionBoundaries | undefined;
|
|
24
33
|
requestId: string;
|
|
25
34
|
turnId: string;
|
|
26
35
|
code: string;
|
|
@@ -191,6 +191,7 @@ export async function handlePrompt(ws, config, command) {
|
|
|
191
191
|
let turnReleased = false;
|
|
192
192
|
let terminalNotification = false;
|
|
193
193
|
let terminalNotified = false;
|
|
194
|
+
const boundaries = { daemonAcceptedAt: Date.now() };
|
|
194
195
|
const releasePromptTurn = () => {
|
|
195
196
|
if (turnReleased)
|
|
196
197
|
return;
|
|
@@ -298,6 +299,7 @@ export async function handlePrompt(ws, config, command) {
|
|
|
298
299
|
const runtimePrompt = await prepareRuntimePromptForSession(config, session, prompt, memoryContext.text);
|
|
299
300
|
const runtimeText = runtimePrompt.text;
|
|
300
301
|
phase = "start-turn";
|
|
302
|
+
boundaries.providerStartAt = Date.now();
|
|
301
303
|
const turn = await startRuntimePromptTurn({
|
|
302
304
|
session,
|
|
303
305
|
command,
|
|
@@ -310,6 +312,7 @@ export async function handlePrompt(ws, config, command) {
|
|
|
310
312
|
? { manualHandoffProof: fallback?.manualHandoffProof ?? session.manualHandoffProof }
|
|
311
313
|
: {}),
|
|
312
314
|
});
|
|
315
|
+
boundaries.providerAcceptedAt = Date.now();
|
|
313
316
|
phase = "attach-turn";
|
|
314
317
|
if (!attachClaimedTurn(command.sessionId, command.requestId, command.turnId, turn)) {
|
|
315
318
|
throw new Error(`Session turn claim was lost before start: ${command.sessionId}`);
|
|
@@ -317,6 +320,7 @@ export async function handlePrompt(ws, config, command) {
|
|
|
317
320
|
phase = "persist-runtime-start";
|
|
318
321
|
const runtimeThreadId = session.runtimeBinding.threadId;
|
|
319
322
|
const runtimeTurnId = await turn.runtimeTurnId;
|
|
323
|
+
boundaries.providerTurnId = runtimeTurnId;
|
|
320
324
|
if (!runtimeTurnId) {
|
|
321
325
|
throw new Error(`Runtime did not return a turn id for ${command.turnId}`);
|
|
322
326
|
}
|
|
@@ -357,6 +361,7 @@ export async function handlePrompt(ws, config, command) {
|
|
|
357
361
|
thinkingLevel,
|
|
358
362
|
memoryMode: memoryOptions?.mode,
|
|
359
363
|
usedMemories: memoryContext.used.map((item) => ({ path: item.path, citation: item.citation })),
|
|
364
|
+
boundaries,
|
|
360
365
|
},
|
|
361
366
|
});
|
|
362
367
|
let outputPreview = "";
|
|
@@ -371,6 +376,7 @@ export async function handlePrompt(ws, config, command) {
|
|
|
371
376
|
}
|
|
372
377
|
phase = "await-turn-result";
|
|
373
378
|
const result = await turn.result;
|
|
379
|
+
boundaries.providerTerminalAt = Date.now();
|
|
374
380
|
session = withSessionHead(session, result.currentHead, result.lastTurnId);
|
|
375
381
|
sessions.set(command.sessionId, session);
|
|
376
382
|
await persistSessionProjection(session);
|
|
@@ -409,6 +415,7 @@ export async function handlePrompt(ws, config, command) {
|
|
|
409
415
|
runtimeMetadataRefreshedAt: new Date().toISOString(),
|
|
410
416
|
});
|
|
411
417
|
phase = "emit-terminal";
|
|
418
|
+
boundaries.terminalEventAt = Date.now();
|
|
412
419
|
clearPendingOptionRequestsForTurn(command.sessionId, command.turnId);
|
|
413
420
|
const completedRuntimeTurnId = result.lastTurnId ?? result.currentHead ?? runtimeTurnId;
|
|
414
421
|
await emitEvent(ws, config, command.sessionId, command.turnId, result.status === "failed"
|
|
@@ -525,6 +532,7 @@ export async function handlePrompt(ws, config, command) {
|
|
|
525
532
|
requestId: command.requestId,
|
|
526
533
|
turnId: command.turnId,
|
|
527
534
|
...(result.status === "failed" ? { error: result.error.message } : { stopReason: result.stopReason }),
|
|
535
|
+
boundaries,
|
|
528
536
|
},
|
|
529
537
|
});
|
|
530
538
|
}
|
|
@@ -540,7 +548,7 @@ export async function handlePrompt(ws, config, command) {
|
|
|
540
548
|
await emitPromptFailureEvent(ws, config, command, phase, error).catch((eventError) => {
|
|
541
549
|
console.error(`Prompt failure event failed for ${command.turnId}: ${eventError instanceof Error ? eventError.message : String(eventError)}`);
|
|
542
550
|
});
|
|
543
|
-
await appendAudit(promptFailureAuditEvent(config, command, phase, error)).catch((auditError) => {
|
|
551
|
+
await appendAudit(promptFailureAuditEvent(config, command, phase, error, boundaries)).catch((auditError) => {
|
|
544
552
|
console.error(`Prompt failure audit failed for ${command.turnId}: ${auditError instanceof Error ? auditError.message : String(auditError)}`);
|
|
545
553
|
});
|
|
546
554
|
await sendCommandResponse(ws, response);
|
|
@@ -784,7 +792,7 @@ export function promptFailureResponse(command, phase, error) {
|
|
|
784
792
|
message: promptFailureMessage(code, error),
|
|
785
793
|
};
|
|
786
794
|
}
|
|
787
|
-
export function promptFailureAuditEvent(config, command, phase, error) {
|
|
795
|
+
export function promptFailureAuditEvent(config, command, phase, error, boundaries) {
|
|
788
796
|
const code = promptFailureCode(phase, error);
|
|
789
797
|
return {
|
|
790
798
|
sessionId: command.sessionId,
|
|
@@ -798,6 +806,7 @@ export function promptFailureAuditEvent(config, command, phase, error) {
|
|
|
798
806
|
code,
|
|
799
807
|
phase,
|
|
800
808
|
message: safeErrorMessage(error),
|
|
809
|
+
...(boundaries ? { boundaries } : {}),
|
|
801
810
|
},
|
|
802
811
|
};
|
|
803
812
|
}
|
|
@@ -74,6 +74,7 @@ export function isUpdateAdmissionCommand(type) {
|
|
|
74
74
|
type === "machine:prompt" ||
|
|
75
75
|
type === "machine:dispatch" ||
|
|
76
76
|
type === "machine:queue" ||
|
|
77
|
+
type === "machine:followup" ||
|
|
77
78
|
type === "machine:optionResponse" ||
|
|
78
79
|
type === "machine:resume" ||
|
|
79
80
|
type === "machine:fork" ||
|