fix: stabilize OpenCode team runtime delivery
This commit is contained in:
parent
ab50c43383
commit
bbafedf06a
17 changed files with 935 additions and 295 deletions
|
|
@ -305,6 +305,9 @@ interface PersistedRuntimeMemberLike {
|
||||||
backendType?: string;
|
backendType?: string;
|
||||||
providerId?: string;
|
providerId?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
bootstrapExpectedAfter?: string;
|
||||||
|
bootstrapProofToken?: string;
|
||||||
|
bootstrapRuntimeEventsPath?: string;
|
||||||
runtimePid?: number;
|
runtimePid?: number;
|
||||||
runtimeSessionId?: string;
|
runtimeSessionId?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -338,6 +341,40 @@ interface LaunchStateWriteResult {
|
||||||
|
|
||||||
type BootstrapTranscriptSuccessSource = 'member_briefing' | 'assistant_text';
|
type BootstrapTranscriptSuccessSource = 'member_briefing' | 'assistant_text';
|
||||||
|
|
||||||
|
const BOOTSTRAP_RUNTIME_PROOF_SOURCE = 'member_briefing_tool_success';
|
||||||
|
const BOOTSTRAP_RUNTIME_PROOF_TAIL_BYTES = 256 * 1024;
|
||||||
|
|
||||||
|
function sanitizeRuntimeEventFilePrefix(value: string): string {
|
||||||
|
return String(value || 'default')
|
||||||
|
.replace(/[^a-zA-Z0-9]/g, '-')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRuntimeBootstrapProofDetail(detail: unknown): Record<string, unknown> {
|
||||||
|
if (typeof detail !== 'string' || detail.trim().length === 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(detail) as unknown;
|
||||||
|
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuntimeBootstrapProofString(
|
||||||
|
event: Record<string, unknown>,
|
||||||
|
detail: Record<string, unknown>,
|
||||||
|
field: 'source' | 'bootstrapProofToken'
|
||||||
|
): string | undefined {
|
||||||
|
const direct = event[field];
|
||||||
|
if (typeof direct === 'string' && direct.trim().length > 0) {
|
||||||
|
return direct.trim();
|
||||||
|
}
|
||||||
|
const nested = detail[field];
|
||||||
|
return typeof nested === 'string' && nested.trim().length > 0 ? nested.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
type BootstrapTranscriptOutcome =
|
type BootstrapTranscriptOutcome =
|
||||||
| {
|
| {
|
||||||
kind: 'success';
|
kind: 'success';
|
||||||
|
|
@ -2658,6 +2695,14 @@ function isBootstrapMcpResourceReadFailureReason(reason?: string): boolean {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBootstrapCheckInTimeoutFailureReason(reason?: string): boolean {
|
||||||
|
return reason?.trim() === 'Teammate was registered but did not bootstrap-confirm before timeout.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBootstrapInstructionPromptFailureReason(reason?: string): boolean {
|
||||||
|
return typeof reason === 'string' && isBootstrapInstructionPrompt(reason);
|
||||||
|
}
|
||||||
|
|
||||||
function isTmuxNoServerRunningError(error: unknown): boolean {
|
function isTmuxNoServerRunningError(error: unknown): boolean {
|
||||||
const text = error instanceof Error ? error.message : String(error ?? '');
|
const text = error instanceof Error ? error.message : String(error ?? '');
|
||||||
return (
|
return (
|
||||||
|
|
@ -2673,7 +2718,9 @@ function isAutoClearableLaunchFailureReason(reason?: string): boolean {
|
||||||
isConfigRegistrationFailureReason(reason) ||
|
isConfigRegistrationFailureReason(reason) ||
|
||||||
isRegisteredRuntimeMetadataFailureReason(reason) ||
|
isRegisteredRuntimeMetadataFailureReason(reason) ||
|
||||||
isOpenCodeBridgeLaunchFailureReason(reason) ||
|
isOpenCodeBridgeLaunchFailureReason(reason) ||
|
||||||
isBootstrapMcpResourceReadFailureReason(reason)
|
isBootstrapMcpResourceReadFailureReason(reason) ||
|
||||||
|
isBootstrapCheckInTimeoutFailureReason(reason) ||
|
||||||
|
isBootstrapInstructionPromptFailureReason(reason)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6605,9 +6652,34 @@ export class TeamProvisioningService {
|
||||||
if (state === 'empty_assistant_turn') {
|
if (state === 'empty_assistant_turn') {
|
||||||
return 'empty_assistant_turn';
|
return 'empty_assistant_turn';
|
||||||
}
|
}
|
||||||
|
if (state === 'prompt_delivered_no_assistant_message') {
|
||||||
|
return 'prompt_delivered_no_assistant_message';
|
||||||
|
}
|
||||||
return record?.lastReason ?? 'opencode_delivery_response_pending';
|
return record?.lastReason ?? 'opencode_delivery_response_pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeOpenCodeDeliveryResponseObservation(
|
||||||
|
observation?: NonNullable<OpenCodeTeamRuntimeMessageResult['responseObservation']>
|
||||||
|
): NonNullable<OpenCodeTeamRuntimeMessageResult['responseObservation']> | undefined {
|
||||||
|
if (
|
||||||
|
observation?.state !== 'empty_assistant_turn' ||
|
||||||
|
!observation.deliveredUserMessageId ||
|
||||||
|
observation.assistantMessageId ||
|
||||||
|
observation.latestAssistantPreview?.trim() ||
|
||||||
|
observation.toolCallNames.length > 0 ||
|
||||||
|
observation.visibleMessageToolCallId ||
|
||||||
|
observation.visibleReplyMessageId
|
||||||
|
) {
|
||||||
|
return observation;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...observation,
|
||||||
|
state: 'prompt_delivered_no_assistant_message',
|
||||||
|
reason: 'prompt_delivered_no_assistant_message',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private isOpenCodeDeliveryRetryablePendingResponse(input: {
|
private isOpenCodeDeliveryRetryablePendingResponse(input: {
|
||||||
ledgerRecord: OpenCodePromptDeliveryLedgerRecord;
|
ledgerRecord: OpenCodePromptDeliveryLedgerRecord;
|
||||||
visibleReply?: OpenCodeVisibleReplyProof | null;
|
visibleReply?: OpenCodeVisibleReplyProof | null;
|
||||||
|
|
@ -7494,7 +7566,7 @@ export class TeamProvisioningService {
|
||||||
lane.member.name.trim().toLowerCase() === normalizedMemberName.toLowerCase()
|
lane.member.name.trim().toLowerCase() === normalizedMemberName.toLowerCase()
|
||||||
);
|
);
|
||||||
trackedSecondaryLanePresent = liveLane != null;
|
trackedSecondaryLanePresent = liveLane != null;
|
||||||
liveSecondaryLaneRunId = liveLane ? trackedRunId : null;
|
liveSecondaryLaneRunId = liveLane?.runId?.trim() || null;
|
||||||
const liveLaneMember = liveLane
|
const liveLaneMember = liveLane
|
||||||
? (liveLane.result?.members?.[canonicalMemberName] ??
|
? (liveLane.result?.members?.[canonicalMemberName] ??
|
||||||
liveLane.result?.members?.[liveLane.member.name])
|
liveLane.result?.members?.[liveLane.member.name])
|
||||||
|
|
@ -7639,11 +7711,14 @@ export class TeamProvisioningService {
|
||||||
runtimePid: result.runtimePid,
|
runtimePid: result.runtimePid,
|
||||||
reason: 'opencode_delivery_runtime_pid_observed',
|
reason: 'opencode_delivery_runtime_pid_observed',
|
||||||
});
|
});
|
||||||
|
const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation(
|
||||||
|
result.responseObservation
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
delivered: result.ok,
|
delivered: result.ok,
|
||||||
accepted: result.ok,
|
accepted: result.ok,
|
||||||
responsePending: false,
|
responsePending: false,
|
||||||
responseState: result.responseObservation?.state,
|
responseState: responseObservation?.state,
|
||||||
...(result.ok
|
...(result.ok
|
||||||
? {}
|
? {}
|
||||||
: { reason: result.diagnostics[0] ?? 'opencode_message_delivery_failed' }),
|
: { reason: result.diagnostics[0] ?? 'opencode_message_delivery_failed' }),
|
||||||
|
|
@ -7880,9 +7955,12 @@ export class TeamProvisioningService {
|
||||||
runtimePid: observed.runtimePid,
|
runtimePid: observed.runtimePid,
|
||||||
reason: 'opencode_delivery_observe_runtime_pid_observed',
|
reason: 'opencode_delivery_observe_runtime_pid_observed',
|
||||||
});
|
});
|
||||||
|
const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation(
|
||||||
|
observed.responseObservation
|
||||||
|
);
|
||||||
ledgerRecord = await ledger.applyObservation({
|
ledgerRecord = await ledger.applyObservation({
|
||||||
id: ledgerRecord.id,
|
id: ledgerRecord.id,
|
||||||
responseObservation: observed.responseObservation ?? {
|
responseObservation: responseObservation ?? {
|
||||||
state: observed.ok ? 'not_observed' : 'reconcile_failed',
|
state: observed.ok ? 'not_observed' : 'reconcile_failed',
|
||||||
deliveredUserMessageId: null,
|
deliveredUserMessageId: null,
|
||||||
assistantMessageId: null,
|
assistantMessageId: null,
|
||||||
|
|
@ -8004,16 +8082,19 @@ export class TeamProvisioningService {
|
||||||
runtimePid: result.runtimePid,
|
runtimePid: result.runtimePid,
|
||||||
reason: 'opencode_delivery_runtime_pid_observed',
|
reason: 'opencode_delivery_runtime_pid_observed',
|
||||||
});
|
});
|
||||||
|
const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation(
|
||||||
|
result.responseObservation
|
||||||
|
);
|
||||||
if (ledgerRecord && ledger) {
|
if (ledgerRecord && ledger) {
|
||||||
ledgerRecord = await ledger.applyDeliveryResult({
|
ledgerRecord = await ledger.applyDeliveryResult({
|
||||||
id: ledgerRecord.id,
|
id: ledgerRecord.id,
|
||||||
accepted: result.ok,
|
accepted: result.ok,
|
||||||
attempted: true,
|
attempted: true,
|
||||||
responseObservation: result.responseObservation,
|
responseObservation,
|
||||||
sessionId: result.sessionId,
|
sessionId: result.sessionId,
|
||||||
prePromptCursor: result.prePromptCursor,
|
prePromptCursor: result.prePromptCursor,
|
||||||
diagnostics: result.diagnostics,
|
diagnostics: result.diagnostics,
|
||||||
reason: result.ok ? result.responseObservation?.reason : result.diagnostics[0],
|
reason: result.ok ? responseObservation?.reason : result.diagnostics[0],
|
||||||
now: nowIso(),
|
now: nowIso(),
|
||||||
});
|
});
|
||||||
let proof = await this.applyOpenCodeVisibleDestinationProof({
|
let proof = await this.applyOpenCodeVisibleDestinationProof({
|
||||||
|
|
@ -8044,7 +8125,7 @@ export class TeamProvisioningService {
|
||||||
{ accepted: result.ok, reason: ledgerRecord.lastReason ?? result.diagnostics[0] ?? null }
|
{ accepted: result.ok, reason: ledgerRecord.lastReason ?? result.diagnostics[0] ?? null }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const responseState = ledgerRecord?.responseState ?? result.responseObservation?.state;
|
const responseState = ledgerRecord?.responseState ?? responseObservation?.state;
|
||||||
const visibleReply = ledgerRecord
|
const visibleReply = ledgerRecord
|
||||||
? await this.findOpenCodeVisibleReplyByRelayOfMessageId({
|
? await this.findOpenCodeVisibleReplyByRelayOfMessageId({
|
||||||
teamName,
|
teamName,
|
||||||
|
|
@ -8139,15 +8220,15 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
const responseVisibleReplyMessageId =
|
const responseVisibleReplyMessageId =
|
||||||
ledgerRecord?.visibleReplyMessageId ??
|
ledgerRecord?.visibleReplyMessageId ??
|
||||||
result.responseObservation?.visibleReplyMessageId ??
|
responseObservation?.visibleReplyMessageId ??
|
||||||
undefined;
|
undefined;
|
||||||
const responseVisibleReplyCorrelation =
|
const responseVisibleReplyCorrelation =
|
||||||
ledgerRecord?.visibleReplyCorrelation ??
|
ledgerRecord?.visibleReplyCorrelation ??
|
||||||
result.responseObservation?.visibleReplyCorrelation ??
|
responseObservation?.visibleReplyCorrelation ??
|
||||||
undefined;
|
undefined;
|
||||||
const acceptanceUnknown = Boolean(ledgerRecord?.acceptanceUnknown && !result.ok);
|
const acceptanceUnknown = Boolean(ledgerRecord?.acceptanceUnknown && !result.ok);
|
||||||
const responsePending =
|
const responsePending =
|
||||||
acceptanceUnknown || (result.ok && Boolean(ledgerRecord || result.responseObservation))
|
acceptanceUnknown || (result.ok && Boolean(ledgerRecord || responseObservation))
|
||||||
? !readAllowed
|
? !readAllowed
|
||||||
: false;
|
: false;
|
||||||
const pendingReason =
|
const pendingReason =
|
||||||
|
|
@ -8162,8 +8243,8 @@ export class TeamProvisioningService {
|
||||||
: result.diagnostics;
|
: result.diagnostics;
|
||||||
return {
|
return {
|
||||||
delivered: result.ok || acceptanceUnknown,
|
delivered: result.ok || acceptanceUnknown,
|
||||||
...(ledgerRecord || result.responseObservation ? { accepted: result.ok } : {}),
|
...(ledgerRecord || responseObservation ? { accepted: result.ok } : {}),
|
||||||
...(ledgerRecord || result.responseObservation ? { responsePending } : {}),
|
...(ledgerRecord || responseObservation ? { responsePending } : {}),
|
||||||
...(acceptanceUnknown ? { acceptanceUnknown: true } : {}),
|
...(acceptanceUnknown ? { acceptanceUnknown: true } : {}),
|
||||||
...(ledgerRecord
|
...(ledgerRecord
|
||||||
? {
|
? {
|
||||||
|
|
@ -11521,7 +11602,8 @@ export class TeamProvisioningService {
|
||||||
private confirmMemberSpawnStatusFromTranscript(
|
private confirmMemberSpawnStatusFromTranscript(
|
||||||
run: ProvisioningRun,
|
run: ProvisioningRun,
|
||||||
memberName: string,
|
memberName: string,
|
||||||
observedAt: string
|
observedAt: string,
|
||||||
|
source: 'transcript' | 'runtime-proof' = 'transcript'
|
||||||
): void {
|
): void {
|
||||||
const prev = run.memberSpawnStatuses.get(memberName) ?? createInitialMemberSpawnStatusEntry();
|
const prev = run.memberSpawnStatuses.get(memberName) ?? createInitialMemberSpawnStatusEntry();
|
||||||
const updatedAt = nowIso();
|
const updatedAt = nowIso();
|
||||||
|
|
@ -11530,7 +11612,7 @@ export class TeamProvisioningService {
|
||||||
status: 'online',
|
status: 'online',
|
||||||
updatedAt,
|
updatedAt,
|
||||||
agentToolAccepted: true,
|
agentToolAccepted: true,
|
||||||
runtimeAlive: prev.runtimeAlive === true,
|
runtimeAlive: source === 'runtime-proof' ? true : prev.runtimeAlive === true,
|
||||||
bootstrapConfirmed: true,
|
bootstrapConfirmed: true,
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
bootstrapStalled: undefined,
|
bootstrapStalled: undefined,
|
||||||
|
|
@ -11564,7 +11646,13 @@ export class TeamProvisioningService {
|
||||||
run.memberSpawnStatuses.set(memberName, next);
|
run.memberSpawnStatuses.set(memberName, next);
|
||||||
run.pendingMemberRestarts?.delete(memberName);
|
run.pendingMemberRestarts?.delete(memberName);
|
||||||
this.syncMemberLaunchGraceCheck(run, memberName, next);
|
this.syncMemberLaunchGraceCheck(run, memberName, next);
|
||||||
this.appendMemberBootstrapDiagnostic(run, memberName, 'bootstrap confirmed via transcript');
|
this.appendMemberBootstrapDiagnostic(
|
||||||
|
run,
|
||||||
|
memberName,
|
||||||
|
source === 'runtime-proof'
|
||||||
|
? 'bootstrap confirmed via runtime proof'
|
||||||
|
: 'bootstrap confirmed via transcript'
|
||||||
|
);
|
||||||
if (!this.isCurrentTrackedRun(run)) return;
|
if (!this.isCurrentTrackedRun(run)) return;
|
||||||
this.emitMemberSpawnChange(run, memberName);
|
this.emitMemberSpawnChange(run, memberName);
|
||||||
if (run.isLaunch) {
|
if (run.isLaunch) {
|
||||||
|
|
@ -13914,12 +14002,26 @@ export class TeamProvisioningService {
|
||||||
(current.launchState === 'failed_to_start' && !canClearFailedBootstrap) ||
|
(current.launchState === 'failed_to_start' && !canClearFailedBootstrap) ||
|
||||||
current.launchState === 'confirmed_alive' ||
|
current.launchState === 'confirmed_alive' ||
|
||||||
current.bootstrapConfirmed === true ||
|
current.bootstrapConfirmed === true ||
|
||||||
current.agentToolAccepted !== true
|
(current.agentToolAccepted !== true && !canClearFailedBootstrap)
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const acceptedAtMs =
|
const acceptedAtMs =
|
||||||
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
|
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
|
||||||
|
const runtimeProofObservedAt = await this.findBootstrapRuntimeProofObservedAt(
|
||||||
|
run.teamName,
|
||||||
|
memberName,
|
||||||
|
current
|
||||||
|
);
|
||||||
|
if (runtimeProofObservedAt) {
|
||||||
|
this.confirmMemberSpawnStatusFromTranscript(
|
||||||
|
run,
|
||||||
|
memberName,
|
||||||
|
runtimeProofObservedAt,
|
||||||
|
'runtime-proof'
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const transcriptOutcome = await this.findBootstrapTranscriptOutcome(
|
const transcriptOutcome = await this.findBootstrapTranscriptOutcome(
|
||||||
run.teamName,
|
run.teamName,
|
||||||
memberName,
|
memberName,
|
||||||
|
|
@ -22658,6 +22760,19 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
const acceptedAtMs =
|
const acceptedAtMs =
|
||||||
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
|
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
|
||||||
|
if (
|
||||||
|
current.launchState !== 'failed_to_start' ||
|
||||||
|
isAutoClearableLaunchFailureReason(current.hardFailureReason ?? current.runtimeDiagnostic)
|
||||||
|
) {
|
||||||
|
const runtimeProofObservedAt = await this.findBootstrapRuntimeProofObservedAt(
|
||||||
|
snapshot.teamName,
|
||||||
|
expected,
|
||||||
|
current
|
||||||
|
);
|
||||||
|
if (runtimeProofObservedAt) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
const transcriptOutcome = await this.findBootstrapTranscriptOutcome(
|
const transcriptOutcome = await this.findBootstrapTranscriptOutcome(
|
||||||
snapshot.teamName,
|
snapshot.teamName,
|
||||||
expected,
|
expected,
|
||||||
|
|
@ -22673,6 +22788,159 @@ export class TeamProvisioningService {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveBootstrapRuntimeMember(
|
||||||
|
teamName: string,
|
||||||
|
memberName: string
|
||||||
|
): PersistedRuntimeMemberLike | undefined {
|
||||||
|
return this.readPersistedRuntimeMembers(teamName).find((member) => {
|
||||||
|
const candidateName = typeof member.name === 'string' ? member.name.trim() : '';
|
||||||
|
return candidateName.length > 0 && matchesMemberNameOrBase(candidateName, memberName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBootstrapRuntimeEventsPath(
|
||||||
|
teamName: string,
|
||||||
|
memberName: string,
|
||||||
|
runtimeMember: PersistedRuntimeMemberLike | undefined
|
||||||
|
): string {
|
||||||
|
const configuredPath = runtimeMember?.bootstrapRuntimeEventsPath?.trim();
|
||||||
|
if (configuredPath) {
|
||||||
|
return configuredPath;
|
||||||
|
}
|
||||||
|
const filePrefix = sanitizeRuntimeEventFilePrefix(runtimeMember?.name ?? memberName);
|
||||||
|
return path.join(getTeamsBasePath(), teamName, 'runtime', `${filePrefix}.runtime.jsonl`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readRuntimeBootstrapProofEvents(
|
||||||
|
eventsPath: string
|
||||||
|
): Promise<Record<string, unknown>[]> {
|
||||||
|
let handle: fs.promises.FileHandle | null = null;
|
||||||
|
try {
|
||||||
|
handle = await fs.promises.open(eventsPath, 'r');
|
||||||
|
const stat = await handle.stat();
|
||||||
|
if (!stat.isFile() || stat.size <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const start = Math.max(0, stat.size - BOOTSTRAP_RUNTIME_PROOF_TAIL_BYTES);
|
||||||
|
const buffer = Buffer.alloc(stat.size - start);
|
||||||
|
if (buffer.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
await handle.read(buffer, 0, buffer.length, start);
|
||||||
|
const lines = buffer.toString('utf8').split('\n');
|
||||||
|
if (start > 0) {
|
||||||
|
lines.shift();
|
||||||
|
}
|
||||||
|
const events: Record<string, unknown>[] = [];
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line) continue;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line) as unknown;
|
||||||
|
if (
|
||||||
|
parsed &&
|
||||||
|
typeof parsed === 'object' &&
|
||||||
|
(parsed as { version?: unknown }).version === 1 &&
|
||||||
|
typeof (parsed as { type?: unknown }).type === 'string' &&
|
||||||
|
typeof (parsed as { timestamp?: unknown }).timestamp === 'string'
|
||||||
|
) {
|
||||||
|
events.push(parsed as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore partial lines from concurrently written runtime event files.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
await handle?.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRuntimeBootstrapProofEventValid(input: {
|
||||||
|
event: Record<string, unknown>;
|
||||||
|
detail: Record<string, unknown>;
|
||||||
|
teamName: string;
|
||||||
|
memberName: string;
|
||||||
|
runtimeMember?: PersistedRuntimeMemberLike;
|
||||||
|
boundaryMs: number;
|
||||||
|
}): boolean {
|
||||||
|
const { event, detail, teamName, memberName, runtimeMember, boundaryMs } = input;
|
||||||
|
if (event.type !== 'bootstrap_confirmed') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof event.teamName === 'string' && event.teamName.trim() !== teamName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const source = getRuntimeBootstrapProofString(event, detail, 'source');
|
||||||
|
if (source !== BOOTSTRAP_RUNTIME_PROOF_SOURCE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const timestamp = typeof event.timestamp === 'string' ? event.timestamp : '';
|
||||||
|
const eventMs = Date.parse(timestamp);
|
||||||
|
if (Number.isFinite(boundaryMs) && (!Number.isFinite(eventMs) || eventMs < boundaryMs)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const expectedToken = runtimeMember?.bootstrapProofToken?.trim();
|
||||||
|
const eventToken = getRuntimeBootstrapProofString(event, detail, 'bootstrapProofToken');
|
||||||
|
if (expectedToken && eventToken !== expectedToken) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const eventAgentName = typeof event.agentName === 'string' ? event.agentName.trim() : '';
|
||||||
|
const eventAgentId = typeof event.agentId === 'string' ? event.agentId.trim() : '';
|
||||||
|
const runtimeName = runtimeMember?.name?.trim() ?? '';
|
||||||
|
const runtimeAgentId = runtimeMember?.agentId?.trim() ?? '';
|
||||||
|
return (
|
||||||
|
(eventAgentName.length > 0 &&
|
||||||
|
(matchesMemberNameOrBase(eventAgentName, memberName) ||
|
||||||
|
(runtimeName.length > 0 && matchesTeamMemberIdentity(eventAgentName, runtimeName)))) ||
|
||||||
|
(eventAgentId.length > 0 && runtimeAgentId.length > 0 && eventAgentId === runtimeAgentId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findBootstrapRuntimeProofObservedAt(
|
||||||
|
teamName: string,
|
||||||
|
memberName: string,
|
||||||
|
member: Pick<
|
||||||
|
PersistedTeamLaunchMemberState,
|
||||||
|
'firstSpawnAcceptedAt' | 'launchState' | 'hardFailureReason'
|
||||||
|
>
|
||||||
|
): Promise<string | null> {
|
||||||
|
const runtimeMember = this.resolveBootstrapRuntimeMember(teamName, memberName);
|
||||||
|
const boundaryText = member.firstSpawnAcceptedAt ?? runtimeMember?.bootstrapExpectedAfter;
|
||||||
|
const boundaryMs = boundaryText ? Date.parse(boundaryText) : Number.NaN;
|
||||||
|
if (!runtimeMember?.bootstrapProofToken && !Number.isFinite(boundaryMs)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const eventsPath = this.getBootstrapRuntimeEventsPath(teamName, memberName, runtimeMember);
|
||||||
|
const events = await this.readRuntimeBootstrapProofEvents(eventsPath);
|
||||||
|
let latest: string | null = null;
|
||||||
|
let latestMs = Number.NEGATIVE_INFINITY;
|
||||||
|
for (const event of events) {
|
||||||
|
const detail = parseRuntimeBootstrapProofDetail(event.detail);
|
||||||
|
if (
|
||||||
|
!this.isRuntimeBootstrapProofEventValid({
|
||||||
|
event,
|
||||||
|
detail,
|
||||||
|
teamName,
|
||||||
|
memberName,
|
||||||
|
runtimeMember,
|
||||||
|
boundaryMs,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const timestamp = typeof event.timestamp === 'string' ? event.timestamp : '';
|
||||||
|
const timestampMs = Date.parse(timestamp);
|
||||||
|
if (Number.isFinite(timestampMs) && timestampMs >= latestMs) {
|
||||||
|
latest = timestamp;
|
||||||
|
latestMs = timestampMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
private async applyBootstrapTranscriptEvidenceOverlay(
|
private async applyBootstrapTranscriptEvidenceOverlay(
|
||||||
snapshot: PersistedTeamLaunchSnapshot | null
|
snapshot: PersistedTeamLaunchSnapshot | null
|
||||||
): Promise<PersistedTeamLaunchSnapshot | null> {
|
): Promise<PersistedTeamLaunchSnapshot | null> {
|
||||||
|
|
@ -22691,15 +22959,30 @@ export class TeamProvisioningService {
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const failureReason = current.hardFailureReason ?? current.runtimeDiagnostic;
|
||||||
|
const canClearFailedBootstrap =
|
||||||
|
current.launchState !== 'failed_to_start' ||
|
||||||
|
isAutoClearableLaunchFailureReason(failureReason);
|
||||||
|
if (!canClearFailedBootstrap) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const acceptedAtMs =
|
const acceptedAtMs =
|
||||||
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
|
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
|
||||||
|
const runtimeProofObservedAt = await this.findBootstrapRuntimeProofObservedAt(
|
||||||
|
snapshot.teamName,
|
||||||
|
expected,
|
||||||
|
current
|
||||||
|
);
|
||||||
const transcriptOutcome = await this.findBootstrapTranscriptOutcome(
|
const transcriptOutcome = await this.findBootstrapTranscriptOutcome(
|
||||||
snapshot.teamName,
|
snapshot.teamName,
|
||||||
expected,
|
expected,
|
||||||
Number.isFinite(acceptedAtMs) ? acceptedAtMs : null
|
Number.isFinite(acceptedAtMs) ? acceptedAtMs : null
|
||||||
);
|
);
|
||||||
if (transcriptOutcome?.kind !== 'success') {
|
const observedAt =
|
||||||
|
runtimeProofObservedAt ??
|
||||||
|
(transcriptOutcome?.kind === 'success' ? transcriptOutcome.observedAt : null);
|
||||||
|
if (!observedAt) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -22707,9 +22990,13 @@ export class TeamProvisioningService {
|
||||||
...current,
|
...current,
|
||||||
agentToolAccepted: true,
|
agentToolAccepted: true,
|
||||||
bootstrapConfirmed: true,
|
bootstrapConfirmed: true,
|
||||||
|
runtimeAlive: runtimeProofObservedAt ? true : current.runtimeAlive === true,
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
hardFailureReason: undefined,
|
hardFailureReason: undefined,
|
||||||
lastHeartbeatAt: current.lastHeartbeatAt ?? transcriptOutcome.observedAt,
|
lastHeartbeatAt: current.lastHeartbeatAt ?? observedAt,
|
||||||
|
lastRuntimeAliveAt: runtimeProofObservedAt
|
||||||
|
? (current.lastRuntimeAliveAt ?? observedAt)
|
||||||
|
: current.lastRuntimeAliveAt,
|
||||||
lastEvaluatedAt: nowIso(),
|
lastEvaluatedAt: nowIso(),
|
||||||
sources: {
|
sources: {
|
||||||
...(current.sources ?? {}),
|
...(current.sources ?? {}),
|
||||||
|
|
@ -22997,6 +23284,8 @@ export class TeamProvisioningService {
|
||||||
: null;
|
: null;
|
||||||
const acceptedAtMs =
|
const acceptedAtMs =
|
||||||
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
|
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
|
||||||
|
const initialFailureReason = current.hardFailureReason ?? current.runtimeDiagnostic;
|
||||||
|
const hadAutoClearableFailure = isAutoClearableLaunchFailureReason(initialFailureReason);
|
||||||
current.runtimeAlive = observedRuntimeAlive;
|
current.runtimeAlive = observedRuntimeAlive;
|
||||||
current.lastRuntimeAliveAt = observedRuntimeAlive ? now : current.lastRuntimeAliveAt;
|
current.lastRuntimeAliveAt = observedRuntimeAlive ? now : current.lastRuntimeAliveAt;
|
||||||
current.livenessKind = runtimeMetadata?.[1].livenessKind;
|
current.livenessKind = runtimeMetadata?.[1].livenessKind;
|
||||||
|
|
@ -23019,7 +23308,7 @@ export class TeamProvisioningService {
|
||||||
const currentProvesSpawnAcceptance =
|
const currentProvesSpawnAcceptance =
|
||||||
current.agentToolAccepted === true || typeof current.firstSpawnAcceptedAt === 'string';
|
current.agentToolAccepted === true || typeof current.firstSpawnAcceptedAt === 'string';
|
||||||
if (
|
if (
|
||||||
isAutoClearableLaunchFailureReason(current.hardFailureReason) &&
|
hadAutoClearableFailure &&
|
||||||
(bootstrapProvesSpawnAcceptance || currentProvesSpawnAcceptance)
|
(bootstrapProvesSpawnAcceptance || currentProvesSpawnAcceptance)
|
||||||
) {
|
) {
|
||||||
current.hardFailure = false;
|
current.hardFailure = false;
|
||||||
|
|
@ -23049,15 +23338,34 @@ export class TeamProvisioningService {
|
||||||
current.hardFailure = false;
|
current.hardFailure = false;
|
||||||
current.hardFailureReason = undefined;
|
current.hardFailureReason = undefined;
|
||||||
}
|
}
|
||||||
if (!current.bootstrapConfirmed) {
|
const canApplyBootstrapSuccess =
|
||||||
const transcriptOutcome = await this.findBootstrapTranscriptOutcome(
|
!heartbeatReason &&
|
||||||
teamName,
|
(current.launchState !== 'failed_to_start' ||
|
||||||
expected,
|
hadAutoClearableFailure ||
|
||||||
Number.isFinite(acceptedAtMs) ? acceptedAtMs : null
|
isAutoClearableLaunchFailureReason(
|
||||||
);
|
current.hardFailureReason ?? current.runtimeDiagnostic
|
||||||
if (transcriptOutcome?.kind === 'success' && !isOpenCodeSecondaryLaneMember) {
|
));
|
||||||
|
if (!current.bootstrapConfirmed && canApplyBootstrapSuccess) {
|
||||||
|
const runtimeProofObservedAt = !isOpenCodeSecondaryLaneMember
|
||||||
|
? await this.findBootstrapRuntimeProofObservedAt(teamName, expected, current)
|
||||||
|
: null;
|
||||||
|
const transcriptOutcome = runtimeProofObservedAt
|
||||||
|
? null
|
||||||
|
: await this.findBootstrapTranscriptOutcome(
|
||||||
|
teamName,
|
||||||
|
expected,
|
||||||
|
Number.isFinite(acceptedAtMs) ? acceptedAtMs : null
|
||||||
|
);
|
||||||
|
const bootstrapObservedAt =
|
||||||
|
runtimeProofObservedAt ??
|
||||||
|
(transcriptOutcome?.kind === 'success' ? transcriptOutcome.observedAt : null);
|
||||||
|
if (bootstrapObservedAt && !isOpenCodeSecondaryLaneMember) {
|
||||||
current.bootstrapConfirmed = true;
|
current.bootstrapConfirmed = true;
|
||||||
current.lastHeartbeatAt = current.lastHeartbeatAt ?? transcriptOutcome.observedAt;
|
current.lastHeartbeatAt = current.lastHeartbeatAt ?? bootstrapObservedAt;
|
||||||
|
current.runtimeAlive = runtimeProofObservedAt ? true : current.runtimeAlive === true;
|
||||||
|
current.lastRuntimeAliveAt = runtimeProofObservedAt
|
||||||
|
? (current.lastRuntimeAliveAt ?? bootstrapObservedAt)
|
||||||
|
: current.lastRuntimeAliveAt;
|
||||||
current.hardFailure = false;
|
current.hardFailure = false;
|
||||||
current.hardFailureReason = undefined;
|
current.hardFailureReason = undefined;
|
||||||
if (current.sources) {
|
if (current.sources) {
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,7 @@ export type OpenCodeDeliveryResponseState =
|
||||||
| 'permission_blocked'
|
| 'permission_blocked'
|
||||||
| 'tool_error'
|
| 'tool_error'
|
||||||
| 'empty_assistant_turn'
|
| 'empty_assistant_turn'
|
||||||
|
| 'prompt_delivered_no_assistant_message'
|
||||||
| 'session_stale'
|
| 'session_stale'
|
||||||
| 'session_error'
|
| 'session_error'
|
||||||
| 'reconcile_failed';
|
| 'reconcile_failed';
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ const OPENCODE_DELIVERY_RESPONSE_STATES = new Set<OpenCodeDeliveryResponseState>
|
||||||
'permission_blocked',
|
'permission_blocked',
|
||||||
'tool_error',
|
'tool_error',
|
||||||
'empty_assistant_turn',
|
'empty_assistant_turn',
|
||||||
|
'prompt_delivered_no_assistant_message',
|
||||||
'session_stale',
|
'session_stale',
|
||||||
'session_error',
|
'session_error',
|
||||||
'reconcile_failed',
|
'reconcile_failed',
|
||||||
|
|
@ -274,7 +275,7 @@ export class OpenCodePromptDeliveryLedgerStore {
|
||||||
const responseState =
|
const responseState =
|
||||||
observation?.state ?? (input.accepted ? record.responseState : 'not_observed');
|
observation?.state ?? (input.accepted ? record.responseState : 'not_observed');
|
||||||
const responded = isOpenCodePromptResponseStateResponded(responseState);
|
const responded = isOpenCodePromptResponseStateResponded(responseState);
|
||||||
const unanswered = responseState === 'empty_assistant_turn';
|
const unanswered = isOpenCodePromptDeliveryUnansweredResponseState(responseState);
|
||||||
return {
|
return {
|
||||||
...record,
|
...record,
|
||||||
status: input.accepted
|
status: input.accepted
|
||||||
|
|
@ -321,7 +322,9 @@ export class OpenCodePromptDeliveryLedgerStore {
|
||||||
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
|
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
|
||||||
return await this.updateExisting(input.id, (record) => {
|
return await this.updateExisting(input.id, (record) => {
|
||||||
const responded = isOpenCodePromptResponseStateResponded(input.responseObservation.state);
|
const responded = isOpenCodePromptResponseStateResponded(input.responseObservation.state);
|
||||||
const unanswered = input.responseObservation.state === 'empty_assistant_turn';
|
const unanswered = isOpenCodePromptDeliveryUnansweredResponseState(
|
||||||
|
input.responseObservation.state
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
...record,
|
...record,
|
||||||
status: responded
|
status: responded
|
||||||
|
|
@ -637,6 +640,12 @@ export function isOpenCodePromptResponseStateResponded(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isOpenCodePromptDeliveryUnansweredResponseState(
|
||||||
|
state: OpenCodeDeliveryResponseState
|
||||||
|
): boolean {
|
||||||
|
return state === 'empty_assistant_turn' || state === 'prompt_delivered_no_assistant_message';
|
||||||
|
}
|
||||||
|
|
||||||
export function isOpenCodePromptDeliveryAttemptDue(
|
export function isOpenCodePromptDeliveryAttemptDue(
|
||||||
record: OpenCodePromptDeliveryLedgerRecord,
|
record: OpenCodePromptDeliveryLedgerRecord,
|
||||||
nowMs: number = Date.now()
|
nowMs: number = Date.now()
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ export function isOpenCodePromptDeliveryRetryableResponseState(
|
||||||
): boolean {
|
): boolean {
|
||||||
return (
|
return (
|
||||||
state === 'empty_assistant_turn' ||
|
state === 'empty_assistant_turn' ||
|
||||||
|
state === 'prompt_delivered_no_assistant_message' ||
|
||||||
state === 'tool_error' ||
|
state === 'tool_error' ||
|
||||||
state === 'reconcile_failed' ||
|
state === 'reconcile_failed' ||
|
||||||
state === 'not_observed' ||
|
state === 'not_observed' ||
|
||||||
|
|
|
||||||
|
|
@ -798,6 +798,7 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
|
||||||
? `Include relayOfMessageId="${input.messageId}" in that message_send call.`
|
? `Include relayOfMessageId="${input.messageId}" in that message_send call.`
|
||||||
: null,
|
: null,
|
||||||
'After the message_send tool call succeeds, stop immediately. Do not send follow-up confirmations or repeat the same answer.',
|
'After the message_send tool call succeeds, stop immediately. Do not send follow-up confirmations or repeat the same answer.',
|
||||||
|
'You must not end this turn empty.',
|
||||||
'Do not call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.',
|
'Do not call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.',
|
||||||
'Do not answer only with plain assistant text when agent-teams_message_send is available.',
|
'Do not answer only with plain assistant text when agent-teams_message_send is available.',
|
||||||
'Do not use SendMessage or runtime_deliver_message for ordinary visible replies.',
|
'Do not use SendMessage or runtime_deliver_message for ordinary visible replies.',
|
||||||
|
|
|
||||||
|
|
@ -345,6 +345,33 @@ interface LeadContextBridgeProps {
|
||||||
isThisTabActive: boolean;
|
isThisTabActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMPTY_MESSAGES_PANEL_TASKS: TeamTaskWithKanban[] = [];
|
||||||
|
|
||||||
|
function buildMessagesPanelTasksSignature(tasks: readonly TeamTaskWithKanban[]): string {
|
||||||
|
return JSON.stringify(
|
||||||
|
tasks.map((task) => [
|
||||||
|
task.id,
|
||||||
|
task.displayId ?? '',
|
||||||
|
task.subject,
|
||||||
|
task.owner ?? '',
|
||||||
|
task.reviewer ?? '',
|
||||||
|
task.status,
|
||||||
|
task.reviewState ?? '',
|
||||||
|
task.kanbanColumn ?? '',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useStableMessagesPanelTasks(
|
||||||
|
tasks: TeamTaskWithKanban[] | undefined
|
||||||
|
): TeamTaskWithKanban[] {
|
||||||
|
const sourceTasks = tasks ?? EMPTY_MESSAGES_PANEL_TASKS;
|
||||||
|
const signature = useMemo(() => buildMessagesPanelTasksSignature(sourceTasks), [sourceTasks]);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- sourceTasks identity is gated by render-relevant task fields.
|
||||||
|
return useMemo(() => sourceTasks, [signature]);
|
||||||
|
}
|
||||||
|
|
||||||
// Codex/OpenCode lead sessions do not expose the Claude-style context data this panel expects yet.
|
// Codex/OpenCode lead sessions do not expose the Claude-style context data this panel expects yet.
|
||||||
const LEAD_CONTEXT_UNSUPPORTED_PROVIDER_IDS = new Set<TeamProviderId>(['codex', 'opencode']);
|
const LEAD_CONTEXT_UNSUPPORTED_PROVIDER_IDS = new Set<TeamProviderId>(['codex', 'opencode']);
|
||||||
|
|
||||||
|
|
@ -1783,9 +1810,12 @@ export const TeamDetailView = memo(function TeamDetailView({
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleOpenTask = useCallback((task: TeamTaskWithKanban) => {
|
const handleOpenMessagePanelTask = useCallback(
|
||||||
setSelectedTask(task);
|
(task: TeamTaskWithKanban) => {
|
||||||
}, []);
|
handleOpenTaskById(task.id);
|
||||||
|
},
|
||||||
|
[handleOpenTaskById]
|
||||||
|
);
|
||||||
|
|
||||||
const handleTaskIdClick = useCallback(
|
const handleTaskIdClick = useCallback(
|
||||||
(taskId: string) => {
|
(taskId: string) => {
|
||||||
|
|
@ -2017,21 +2047,22 @@ export const TeamDetailView = memo(function TeamDetailView({
|
||||||
})();
|
})();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const messagesPanelTasks = useStableMessagesPanelTasks(data?.tasks);
|
||||||
|
|
||||||
const sharedMessagesPanelProps = useMemo<SharedTeamMessagesPanelProps>(
|
const sharedMessagesPanelProps = useMemo<SharedTeamMessagesPanelProps>(
|
||||||
() => ({
|
() => ({
|
||||||
teamName,
|
teamName,
|
||||||
onPositionChange: changeMessagesPanelMode,
|
onPositionChange: changeMessagesPanelMode,
|
||||||
mountPoint: messagesPanelMountPoint,
|
mountPoint: messagesPanelMountPoint,
|
||||||
members: activeMembers,
|
members: activeMembers,
|
||||||
tasks: data?.tasks ?? [],
|
tasks: messagesPanelTasks,
|
||||||
isTeamAlive: data?.isAlive,
|
isTeamAlive: data?.isAlive,
|
||||||
timeWindow,
|
timeWindow,
|
||||||
teamSessionIds,
|
|
||||||
currentLeadSessionId: data?.config.leadSessionId,
|
currentLeadSessionId: data?.config.leadSessionId,
|
||||||
pendingRepliesByMember,
|
pendingRepliesByMember,
|
||||||
onPendingReplyChange: setPendingRepliesByMember,
|
onPendingReplyChange: setPendingRepliesByMember,
|
||||||
onMemberClick: handleSelectMember,
|
onMemberClick: handleSelectMember,
|
||||||
onTaskClick: handleOpenTask,
|
onTaskClick: handleOpenMessagePanelTask,
|
||||||
onCreateTaskFromMessage: handleCreateTaskFromMessage,
|
onCreateTaskFromMessage: handleCreateTaskFromMessage,
|
||||||
onReplyToMessage: handleReplyToMessage,
|
onReplyToMessage: handleReplyToMessage,
|
||||||
onRestartTeam: handleRestartTeam,
|
onRestartTeam: handleRestartTeam,
|
||||||
|
|
@ -2042,17 +2073,16 @@ export const TeamDetailView = memo(function TeamDetailView({
|
||||||
activeMembers,
|
activeMembers,
|
||||||
data?.config.leadSessionId,
|
data?.config.leadSessionId,
|
||||||
data?.isAlive,
|
data?.isAlive,
|
||||||
data?.tasks,
|
|
||||||
handleCreateTaskFromMessage,
|
handleCreateTaskFromMessage,
|
||||||
handleOpenTask,
|
handleOpenMessagePanelTask,
|
||||||
handleReplyToMessage,
|
handleReplyToMessage,
|
||||||
handleRestartTeam,
|
handleRestartTeam,
|
||||||
handleSelectMember,
|
handleSelectMember,
|
||||||
handleTaskIdClick,
|
handleTaskIdClick,
|
||||||
|
messagesPanelTasks,
|
||||||
messagesPanelMountPoint,
|
messagesPanelMountPoint,
|
||||||
pendingRepliesByMember,
|
pendingRepliesByMember,
|
||||||
teamName,
|
teamName,
|
||||||
teamSessionIds,
|
|
||||||
timeWindow,
|
timeWindow,
|
||||||
changeMessagesPanelMode,
|
changeMessagesPanelMode,
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import {
|
import {
|
||||||
|
type ComponentProps,
|
||||||
memo,
|
memo,
|
||||||
type RefObject,
|
type RefObject,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
|
@ -216,6 +217,109 @@ export function hasVisibleReplyForSendMessageDiagnostics(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MessagesComposerSection = memo(MessageComposer);
|
||||||
|
const MessagesStatusSection = memo(StatusBlock);
|
||||||
|
|
||||||
|
type MessagesTimelineSectionProps = ComponentProps<typeof ActivityTimeline> & {
|
||||||
|
hasMore: boolean;
|
||||||
|
loadingOlderMessages: boolean;
|
||||||
|
onLoadOlderMessages: () => void;
|
||||||
|
expandedItem: TimelineItem | null;
|
||||||
|
expandedItemKey: string | null;
|
||||||
|
onExpandDialogChange: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MessagesTimelineSection = memo(function MessagesTimelineSection({
|
||||||
|
hasMore,
|
||||||
|
loadingOlderMessages,
|
||||||
|
onLoadOlderMessages,
|
||||||
|
expandedItem,
|
||||||
|
expandedItemKey,
|
||||||
|
onExpandDialogChange,
|
||||||
|
messages,
|
||||||
|
teamName,
|
||||||
|
members,
|
||||||
|
readState,
|
||||||
|
allCollapsed,
|
||||||
|
expandOverrides,
|
||||||
|
onToggleExpandOverride,
|
||||||
|
currentLeadSessionId,
|
||||||
|
isTeamAlive,
|
||||||
|
leadActivity,
|
||||||
|
leadContextUpdatedAt,
|
||||||
|
teamNames,
|
||||||
|
teamColorByName,
|
||||||
|
onTeamClick,
|
||||||
|
onMemberClick,
|
||||||
|
onCreateTaskFromMessage,
|
||||||
|
onReplyToMessage,
|
||||||
|
onMessageVisible,
|
||||||
|
onRestartTeam,
|
||||||
|
onTaskIdClick,
|
||||||
|
onExpandItem,
|
||||||
|
onExpandContent,
|
||||||
|
viewport,
|
||||||
|
}: MessagesTimelineSectionProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ActivityTimeline
|
||||||
|
messages={messages}
|
||||||
|
teamName={teamName}
|
||||||
|
members={members}
|
||||||
|
readState={readState}
|
||||||
|
allCollapsed={allCollapsed}
|
||||||
|
expandOverrides={expandOverrides}
|
||||||
|
onToggleExpandOverride={onToggleExpandOverride}
|
||||||
|
currentLeadSessionId={currentLeadSessionId}
|
||||||
|
isTeamAlive={isTeamAlive}
|
||||||
|
leadActivity={leadActivity}
|
||||||
|
leadContextUpdatedAt={leadContextUpdatedAt}
|
||||||
|
teamNames={teamNames}
|
||||||
|
teamColorByName={teamColorByName}
|
||||||
|
onTeamClick={onTeamClick}
|
||||||
|
onMemberClick={onMemberClick}
|
||||||
|
onCreateTaskFromMessage={onCreateTaskFromMessage}
|
||||||
|
onReplyToMessage={onReplyToMessage}
|
||||||
|
onMessageVisible={onMessageVisible}
|
||||||
|
onRestartTeam={onRestartTeam}
|
||||||
|
onTaskIdClick={onTaskIdClick}
|
||||||
|
onExpandItem={onExpandItem}
|
||||||
|
onExpandContent={onExpandContent}
|
||||||
|
viewport={viewport}
|
||||||
|
/>
|
||||||
|
{hasMore && (
|
||||||
|
<div className="flex justify-center py-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs text-text-muted"
|
||||||
|
aria-busy={loadingOlderMessages}
|
||||||
|
disabled={loadingOlderMessages}
|
||||||
|
onClick={onLoadOlderMessages}
|
||||||
|
>
|
||||||
|
Load older messages
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<MessageExpandDialog
|
||||||
|
expandedItem={expandedItem}
|
||||||
|
open={expandedItemKey !== null}
|
||||||
|
onOpenChange={onExpandDialogChange}
|
||||||
|
teamName={teamName}
|
||||||
|
members={members}
|
||||||
|
onCreateTaskFromMessage={onCreateTaskFromMessage}
|
||||||
|
onReplyToMessage={onReplyToMessage}
|
||||||
|
onMemberClick={onMemberClick}
|
||||||
|
onTaskIdClick={onTaskIdClick}
|
||||||
|
onRestartTeam={onRestartTeam}
|
||||||
|
teamNames={teamNames}
|
||||||
|
teamColorByName={teamColorByName}
|
||||||
|
onTeamClick={onTeamClick}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export const MessagesPanel = memo(function MessagesPanel({
|
export const MessagesPanel = memo(function MessagesPanel({
|
||||||
teamName,
|
teamName,
|
||||||
position,
|
position,
|
||||||
|
|
@ -282,8 +386,10 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
await loadOlderTeamMessages(teamName);
|
await loadOlderTeamMessages(teamName);
|
||||||
}, [loadOlderTeamMessages, messagesState, teamName]);
|
}, [loadOlderTeamMessages, messagesState, teamName]);
|
||||||
|
|
||||||
const messagesLoading =
|
const handleLoadOlderMessagesClick = useCallback(() => {
|
||||||
(messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false);
|
void loadOlderMessages();
|
||||||
|
}, [loadOlderMessages]);
|
||||||
|
|
||||||
const loadingOlderMessages = messagesState?.loadingOlder ?? false;
|
const loadingOlderMessages = messagesState?.loadingOlder ?? false;
|
||||||
const hasMore = messagesState?.hasMore ?? false;
|
const hasMore = messagesState?.hasMore ?? false;
|
||||||
const effectiveMessages = messages;
|
const effectiveMessages = messages;
|
||||||
|
|
@ -723,6 +829,99 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
);
|
);
|
||||||
}, [bottomSheetSnapIndex]);
|
}, [bottomSheetSnapIndex]);
|
||||||
|
|
||||||
|
const defaultComposerSection = (
|
||||||
|
<MessagesComposerSection
|
||||||
|
teamName={teamName}
|
||||||
|
members={members}
|
||||||
|
isTeamAlive={isTeamAlive}
|
||||||
|
sending={sendingMessage}
|
||||||
|
sendError={sendMessageError}
|
||||||
|
sendWarning={effectiveSendMessageWarning}
|
||||||
|
sendDebugDetails={effectiveSendMessageDebugDetails}
|
||||||
|
lastResult={lastSendMessageResult}
|
||||||
|
textareaRef={composerTextareaRef}
|
||||||
|
onSend={handleSend}
|
||||||
|
onCrossTeamSend={handleCrossTeamSend}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const compactComposerSection = (
|
||||||
|
<MessagesComposerSection
|
||||||
|
teamName={teamName}
|
||||||
|
layout="compact"
|
||||||
|
members={members}
|
||||||
|
isTeamAlive={isTeamAlive}
|
||||||
|
sending={sendingMessage}
|
||||||
|
sendError={sendMessageError}
|
||||||
|
sendWarning={effectiveSendMessageWarning}
|
||||||
|
sendDebugDetails={effectiveSendMessageDebugDetails}
|
||||||
|
lastResult={lastSendMessageResult}
|
||||||
|
textareaRef={composerTextareaRef}
|
||||||
|
onSend={handleSend}
|
||||||
|
onCrossTeamSend={handleCrossTeamSend}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const inlineStatusSection = (
|
||||||
|
<MessagesStatusSection
|
||||||
|
members={members}
|
||||||
|
tasks={tasks}
|
||||||
|
messages={effectiveMessages}
|
||||||
|
pendingRepliesByMember={pendingRepliesByMember}
|
||||||
|
layout="flow"
|
||||||
|
position="inline"
|
||||||
|
onMemberClick={onMemberClick}
|
||||||
|
onTaskClick={onTaskClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const sidebarStatusSection = (
|
||||||
|
<MessagesStatusSection
|
||||||
|
members={members}
|
||||||
|
tasks={tasks}
|
||||||
|
messages={effectiveMessages}
|
||||||
|
pendingRepliesByMember={pendingRepliesByMember}
|
||||||
|
layout="flow"
|
||||||
|
position="sidebar"
|
||||||
|
onMemberClick={onMemberClick}
|
||||||
|
onTaskClick={onTaskClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const timelineSection = (
|
||||||
|
<MessagesTimelineSection
|
||||||
|
messages={activityTimelineMessages}
|
||||||
|
teamName={teamName}
|
||||||
|
members={members}
|
||||||
|
readState={readState}
|
||||||
|
allCollapsed={messagesCollapsed}
|
||||||
|
expandOverrides={expandedSet}
|
||||||
|
onToggleExpandOverride={toggleExpandOverride}
|
||||||
|
currentLeadSessionId={currentLeadSessionId}
|
||||||
|
isTeamAlive={isTeamAlive}
|
||||||
|
leadActivity={leadActivity}
|
||||||
|
leadContextUpdatedAt={leadContextUpdatedAt}
|
||||||
|
teamNames={teamNames}
|
||||||
|
teamColorByName={teamColorByName}
|
||||||
|
onTeamClick={openTeamTab}
|
||||||
|
onMemberClick={onMemberClick}
|
||||||
|
onCreateTaskFromMessage={onCreateTaskFromMessage}
|
||||||
|
onReplyToMessage={onReplyToMessage}
|
||||||
|
onMessageVisible={handleMessageVisible}
|
||||||
|
onRestartTeam={onRestartTeam}
|
||||||
|
onTaskIdClick={onTaskIdClick}
|
||||||
|
onExpandItem={handleExpandItem}
|
||||||
|
onExpandContent={handleExpandContent}
|
||||||
|
viewport={activityTimelineViewport}
|
||||||
|
hasMore={hasMore}
|
||||||
|
loadingOlderMessages={loadingOlderMessages}
|
||||||
|
onLoadOlderMessages={handleLoadOlderMessagesClick}
|
||||||
|
expandedItem={expandedItem}
|
||||||
|
expandedItemKey={expandedItemKey}
|
||||||
|
onExpandDialogChange={handleExpandDialogChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
// ---- Shared content (used in both modes) ----
|
// ---- Shared content (used in both modes) ----
|
||||||
const searchAndFilterControls = (
|
const searchAndFilterControls = (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -785,83 +984,9 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
|
|
||||||
const messagesContent = (
|
const messagesContent = (
|
||||||
<div className="pb-14">
|
<div className="pb-14">
|
||||||
<MessageComposer
|
{defaultComposerSection}
|
||||||
teamName={teamName}
|
{inlineStatusSection}
|
||||||
members={members}
|
{timelineSection}
|
||||||
isTeamAlive={isTeamAlive}
|
|
||||||
sending={sendingMessage}
|
|
||||||
sendError={sendMessageError}
|
|
||||||
sendWarning={effectiveSendMessageWarning}
|
|
||||||
sendDebugDetails={effectiveSendMessageDebugDetails}
|
|
||||||
lastResult={lastSendMessageResult}
|
|
||||||
textareaRef={composerTextareaRef}
|
|
||||||
onSend={handleSend}
|
|
||||||
onCrossTeamSend={handleCrossTeamSend}
|
|
||||||
/>
|
|
||||||
<StatusBlock
|
|
||||||
members={members}
|
|
||||||
tasks={tasks}
|
|
||||||
messages={effectiveMessages}
|
|
||||||
pendingRepliesByMember={pendingRepliesByMember}
|
|
||||||
layout="flow"
|
|
||||||
position="inline"
|
|
||||||
onMemberClick={onMemberClick}
|
|
||||||
onTaskClick={onTaskClick}
|
|
||||||
/>
|
|
||||||
<ActivityTimeline
|
|
||||||
messages={activityTimelineMessages}
|
|
||||||
teamName={teamName}
|
|
||||||
members={members}
|
|
||||||
readState={readState}
|
|
||||||
allCollapsed={messagesCollapsed}
|
|
||||||
expandOverrides={expandedSet}
|
|
||||||
onToggleExpandOverride={toggleExpandOverride}
|
|
||||||
currentLeadSessionId={currentLeadSessionId}
|
|
||||||
isTeamAlive={isTeamAlive}
|
|
||||||
leadActivity={leadActivity}
|
|
||||||
leadContextUpdatedAt={leadContextUpdatedAt}
|
|
||||||
teamNames={teamNames}
|
|
||||||
teamColorByName={teamColorByName}
|
|
||||||
onTeamClick={openTeamTab}
|
|
||||||
onMemberClick={onMemberClick}
|
|
||||||
onCreateTaskFromMessage={onCreateTaskFromMessage}
|
|
||||||
onReplyToMessage={onReplyToMessage}
|
|
||||||
onMessageVisible={handleMessageVisible}
|
|
||||||
onRestartTeam={onRestartTeam}
|
|
||||||
onTaskIdClick={onTaskIdClick}
|
|
||||||
onExpandItem={handleExpandItem}
|
|
||||||
onExpandContent={handleExpandContent}
|
|
||||||
viewport={activityTimelineViewport}
|
|
||||||
/>
|
|
||||||
{hasMore && (
|
|
||||||
<div className="flex justify-center py-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-xs text-text-muted"
|
|
||||||
aria-busy={loadingOlderMessages}
|
|
||||||
disabled={loadingOlderMessages}
|
|
||||||
onClick={() => void loadOlderMessages()}
|
|
||||||
>
|
|
||||||
Load older messages
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<MessageExpandDialog
|
|
||||||
expandedItem={expandedItem}
|
|
||||||
open={expandedItemKey !== null}
|
|
||||||
onOpenChange={handleExpandDialogChange}
|
|
||||||
teamName={teamName}
|
|
||||||
members={members}
|
|
||||||
onCreateTaskFromMessage={onCreateTaskFromMessage}
|
|
||||||
onReplyToMessage={onReplyToMessage}
|
|
||||||
onMemberClick={onMemberClick}
|
|
||||||
onTaskIdClick={onTaskIdClick}
|
|
||||||
onRestartTeam={onRestartTeam}
|
|
||||||
teamNames={teamNames}
|
|
||||||
teamColorByName={teamColorByName}
|
|
||||||
onTeamClick={openTeamTab}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -972,84 +1097,10 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
onScroll={(e) => setMessagesScrollTop(e.currentTarget.scrollTop)}
|
onScroll={(e) => setMessagesScrollTop(e.currentTarget.scrollTop)}
|
||||||
>
|
>
|
||||||
<div className="pl-3">
|
<div className="pl-3">
|
||||||
<MessageComposer
|
{defaultComposerSection}
|
||||||
teamName={teamName}
|
{sidebarStatusSection}
|
||||||
members={members}
|
|
||||||
isTeamAlive={isTeamAlive}
|
|
||||||
sending={sendingMessage}
|
|
||||||
sendError={sendMessageError}
|
|
||||||
sendWarning={effectiveSendMessageWarning}
|
|
||||||
sendDebugDetails={effectiveSendMessageDebugDetails}
|
|
||||||
lastResult={lastSendMessageResult}
|
|
||||||
textareaRef={composerTextareaRef}
|
|
||||||
onSend={handleSend}
|
|
||||||
onCrossTeamSend={handleCrossTeamSend}
|
|
||||||
/>
|
|
||||||
<StatusBlock
|
|
||||||
members={members}
|
|
||||||
tasks={tasks}
|
|
||||||
messages={effectiveMessages}
|
|
||||||
pendingRepliesByMember={pendingRepliesByMember}
|
|
||||||
layout="flow"
|
|
||||||
position="sidebar"
|
|
||||||
onMemberClick={onMemberClick}
|
|
||||||
onTaskClick={onTaskClick}
|
|
||||||
/>{' '}
|
|
||||||
</div>
|
</div>
|
||||||
<ActivityTimeline
|
{timelineSection}
|
||||||
messages={activityTimelineMessages}
|
|
||||||
teamName={teamName}
|
|
||||||
members={members}
|
|
||||||
readState={readState}
|
|
||||||
allCollapsed={messagesCollapsed}
|
|
||||||
expandOverrides={expandedSet}
|
|
||||||
onToggleExpandOverride={toggleExpandOverride}
|
|
||||||
currentLeadSessionId={currentLeadSessionId}
|
|
||||||
isTeamAlive={isTeamAlive}
|
|
||||||
leadActivity={leadActivity}
|
|
||||||
leadContextUpdatedAt={leadContextUpdatedAt}
|
|
||||||
teamNames={teamNames}
|
|
||||||
teamColorByName={teamColorByName}
|
|
||||||
onTeamClick={openTeamTab}
|
|
||||||
onMemberClick={onMemberClick}
|
|
||||||
onCreateTaskFromMessage={onCreateTaskFromMessage}
|
|
||||||
onReplyToMessage={onReplyToMessage}
|
|
||||||
onMessageVisible={handleMessageVisible}
|
|
||||||
onRestartTeam={onRestartTeam}
|
|
||||||
onTaskIdClick={onTaskIdClick}
|
|
||||||
onExpandItem={handleExpandItem}
|
|
||||||
onExpandContent={handleExpandContent}
|
|
||||||
viewport={activityTimelineViewport}
|
|
||||||
/>
|
|
||||||
{hasMore && (
|
|
||||||
<div className="flex justify-center py-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-xs text-text-muted"
|
|
||||||
aria-busy={loadingOlderMessages}
|
|
||||||
disabled={loadingOlderMessages}
|
|
||||||
onClick={() => void loadOlderMessages()}
|
|
||||||
>
|
|
||||||
Load older messages
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<MessageExpandDialog
|
|
||||||
expandedItem={expandedItem}
|
|
||||||
open={expandedItemKey !== null}
|
|
||||||
onOpenChange={handleExpandDialogChange}
|
|
||||||
teamName={teamName}
|
|
||||||
members={members}
|
|
||||||
onCreateTaskFromMessage={onCreateTaskFromMessage}
|
|
||||||
onReplyToMessage={onReplyToMessage}
|
|
||||||
onMemberClick={onMemberClick}
|
|
||||||
onTaskIdClick={onTaskIdClick}
|
|
||||||
onRestartTeam={onRestartTeam}
|
|
||||||
teamNames={teamNames}
|
|
||||||
teamColorByName={teamColorByName}
|
|
||||||
onTeamClick={openTeamTab}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -1256,91 +1307,10 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
{searchAndFilterControls}
|
{searchAndFilterControls}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="p-3">
|
<div className="p-3">{compactComposerSection}</div>
|
||||||
<MessageComposer
|
|
||||||
teamName={teamName}
|
|
||||||
layout="compact"
|
|
||||||
members={members}
|
|
||||||
isTeamAlive={isTeamAlive}
|
|
||||||
sending={sendingMessage}
|
|
||||||
sendError={sendMessageError}
|
|
||||||
sendWarning={effectiveSendMessageWarning}
|
|
||||||
sendDebugDetails={effectiveSendMessageDebugDetails}
|
|
||||||
lastResult={lastSendMessageResult}
|
|
||||||
textareaRef={composerTextareaRef}
|
|
||||||
onSend={handleSend}
|
|
||||||
onCrossTeamSend={handleCrossTeamSend}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 px-3 pt-2">
|
<div className="shrink-0 px-3 pt-2">{inlineStatusSection}</div>
|
||||||
<StatusBlock
|
<div className="flex-1 px-3 pb-4 pt-2">{timelineSection}</div>
|
||||||
members={members}
|
|
||||||
tasks={tasks}
|
|
||||||
messages={effectiveMessages}
|
|
||||||
pendingRepliesByMember={pendingRepliesByMember}
|
|
||||||
layout="flow"
|
|
||||||
position="inline"
|
|
||||||
onMemberClick={onMemberClick}
|
|
||||||
onTaskClick={onTaskClick}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 px-3 pb-4 pt-2">
|
|
||||||
<ActivityTimeline
|
|
||||||
messages={activityTimelineMessages}
|
|
||||||
teamName={teamName}
|
|
||||||
members={members}
|
|
||||||
readState={readState}
|
|
||||||
allCollapsed={messagesCollapsed}
|
|
||||||
expandOverrides={expandedSet}
|
|
||||||
onToggleExpandOverride={toggleExpandOverride}
|
|
||||||
currentLeadSessionId={currentLeadSessionId}
|
|
||||||
isTeamAlive={isTeamAlive}
|
|
||||||
leadActivity={leadActivity}
|
|
||||||
leadContextUpdatedAt={leadContextUpdatedAt}
|
|
||||||
teamNames={teamNames}
|
|
||||||
teamColorByName={teamColorByName}
|
|
||||||
onTeamClick={openTeamTab}
|
|
||||||
onMemberClick={onMemberClick}
|
|
||||||
onCreateTaskFromMessage={onCreateTaskFromMessage}
|
|
||||||
onReplyToMessage={onReplyToMessage}
|
|
||||||
onMessageVisible={handleMessageVisible}
|
|
||||||
onRestartTeam={onRestartTeam}
|
|
||||||
onTaskIdClick={onTaskIdClick}
|
|
||||||
onExpandItem={handleExpandItem}
|
|
||||||
onExpandContent={handleExpandContent}
|
|
||||||
viewport={activityTimelineViewport}
|
|
||||||
/>
|
|
||||||
{hasMore && (
|
|
||||||
<div className="flex justify-center py-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-xs text-text-muted"
|
|
||||||
aria-busy={loadingOlderMessages}
|
|
||||||
disabled={loadingOlderMessages}
|
|
||||||
onClick={() => void loadOlderMessages()}
|
|
||||||
>
|
|
||||||
Load older messages
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<MessageExpandDialog
|
|
||||||
expandedItem={expandedItem}
|
|
||||||
open={expandedItemKey !== null}
|
|
||||||
onOpenChange={handleExpandDialogChange}
|
|
||||||
teamName={teamName}
|
|
||||||
members={members}
|
|
||||||
onCreateTaskFromMessage={onCreateTaskFromMessage}
|
|
||||||
onReplyToMessage={onReplyToMessage}
|
|
||||||
onMemberClick={onMemberClick}
|
|
||||||
onTaskIdClick={onTaskIdClick}
|
|
||||||
onRestartTeam={onRestartTeam}
|
|
||||||
teamNames={teamNames}
|
|
||||||
teamColorByName={teamColorByName}
|
|
||||||
onTeamClick={openTeamTab}
|
|
||||||
/>
|
|
||||||
</Sheet.Content>
|
</Sheet.Content>
|
||||||
)}
|
)}
|
||||||
</Sheet.Container>
|
</Sheet.Container>
|
||||||
|
|
|
||||||
|
|
@ -66,12 +66,12 @@ export const StatusBlock = ({
|
||||||
return hasActiveTasks;
|
return hasActiveTasks;
|
||||||
}, [hasActiveTasks, hasPendingReplies]);
|
}, [hasActiveTasks, hasPendingReplies]);
|
||||||
|
|
||||||
// Only run the 1-second timer when the block actually has content to show.
|
// Only pending reply TTL labels need a 1-second refresh.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasItems) return;
|
if (!hasPendingReplies) return;
|
||||||
const id = window.setInterval(() => setNowMs(Date.now()), 1000);
|
const id = window.setInterval(() => setNowMs(Date.now()), 1000);
|
||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, [hasItems]);
|
}, [hasPendingReplies]);
|
||||||
|
|
||||||
if (!hasItems) return null;
|
if (!hasItems) return null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -135,8 +135,11 @@ function buildRuntimeBackedDisplayRow(
|
||||||
const hasErrorDiagnostic = runtime.runtimeDiagnosticSeverity === 'error';
|
const hasErrorDiagnostic = runtime.runtimeDiagnosticSeverity === 'error';
|
||||||
const spawnDegradation = getSpawnDegradation(spawn);
|
const spawnDegradation = getSpawnDegradation(spawn);
|
||||||
const state = getRuntimeBackedState(runtime, hasErrorDiagnostic, spawnDegradation != null);
|
const state = getRuntimeBackedState(runtime, hasErrorDiagnostic, spawnDegradation != null);
|
||||||
|
const degradedReason = spawnDegradation
|
||||||
|
? withLiveProcessContext(spawnDegradation.reason, runtime)
|
||||||
|
: undefined;
|
||||||
const stateReason =
|
const stateReason =
|
||||||
spawnDegradation?.reason ??
|
degradedReason ??
|
||||||
runtime.runtimeDiagnostic ??
|
runtime.runtimeDiagnostic ??
|
||||||
(runtime.alive === true ? 'Runtime heartbeat is alive' : 'Runtime heartbeat is not alive');
|
(runtime.alive === true ? 'Runtime heartbeat is alive' : 'Runtime heartbeat is not alive');
|
||||||
|
|
||||||
|
|
@ -151,7 +154,10 @@ function buildRuntimeBackedDisplayRow(
|
||||||
laneId: runtime.laneId,
|
laneId: runtime.laneId,
|
||||||
laneKind: runtime.laneKind,
|
laneKind: runtime.laneKind,
|
||||||
runtimeModel: runtime.runtimeModel,
|
runtimeModel: runtime.runtimeModel,
|
||||||
diagnostic: spawnDegradation?.diagnostic ?? runtime.runtimeDiagnostic,
|
diagnostic:
|
||||||
|
spawnDegradation && degradedReason
|
||||||
|
? withLiveProcessContext(spawnDegradation.diagnostic ?? degradedReason, runtime)
|
||||||
|
: runtime.runtimeDiagnostic,
|
||||||
diagnosticSeverity: spawnDegradation?.diagnosticSeverity ?? runtime.runtimeDiagnosticSeverity,
|
diagnosticSeverity: spawnDegradation?.diagnosticSeverity ?? runtime.runtimeDiagnosticSeverity,
|
||||||
pidLabel: formatRuntimePidLabel(runtime),
|
pidLabel: formatRuntimePidLabel(runtime),
|
||||||
actionsAllowed: false,
|
actionsAllowed: false,
|
||||||
|
|
@ -213,6 +219,13 @@ function getRuntimeBackedState(
|
||||||
return runtime.alive === true ? 'running' : 'stopped';
|
return runtime.alive === true ? 'running' : 'stopped';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withLiveProcessContext(reason: string, runtime: TeamAgentRuntimeEntry): string {
|
||||||
|
if (runtime.alive !== true || /process is still alive/i.test(reason)) {
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
return `${reason}. Process is still alive.`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildSpawnBackedDisplayRow(
|
function buildSpawnBackedDisplayRow(
|
||||||
memberName: string,
|
memberName: string,
|
||||||
spawn: MemberSpawnStatusEntry
|
spawn: MemberSpawnStatusEntry
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | unde
|
||||||
if (normalized === 'empty_assistant_turn') {
|
if (normalized === 'empty_assistant_turn') {
|
||||||
return 'OpenCode returned an empty assistant turn.';
|
return 'OpenCode returned an empty assistant turn.';
|
||||||
}
|
}
|
||||||
|
if (normalized === 'prompt_delivered_no_assistant_message') {
|
||||||
|
return 'OpenCode accepted the prompt, but no assistant turn was recorded.';
|
||||||
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -712,6 +712,7 @@ export interface SendMessageResult {
|
||||||
| 'permission_blocked'
|
| 'permission_blocked'
|
||||||
| 'tool_error'
|
| 'tool_error'
|
||||||
| 'empty_assistant_turn'
|
| 'empty_assistant_turn'
|
||||||
|
| 'prompt_delivered_no_assistant_message'
|
||||||
| 'session_stale'
|
| 'session_stale'
|
||||||
| 'session_error'
|
| 'session_error'
|
||||||
| 'reconcile_failed';
|
| 'reconcile_failed';
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,7 @@ describe('OpenCode production prompt artifacts safe e2e', () => {
|
||||||
expect(directCommand?.text).toContain('Include source="runtime_delivery"');
|
expect(directCommand?.text).toContain('Include source="runtime_delivery"');
|
||||||
expect(directCommand?.text).toContain('Include relayOfMessageId="semantic-direct-');
|
expect(directCommand?.text).toContain('Include relayOfMessageId="semantic-direct-');
|
||||||
expect(directCommand?.text).toContain('Action mode for this message: ask.');
|
expect(directCommand?.text).toContain('Action mode for this message: ask.');
|
||||||
|
expect(directCommand?.text).toContain('You must not end this turn empty.');
|
||||||
expect(directCommand?.text).toContain('"displayId":"59560c95"');
|
expect(directCommand?.text).toContain('"displayId":"59560c95"');
|
||||||
expect(directCommand?.text).toContain('Do not use SendMessage or runtime_deliver_message');
|
expect(directCommand?.text).toContain('Do not use SendMessage or runtime_deliver_message');
|
||||||
expect(directCommand?.text).toContain('never use #00000000');
|
expect(directCommand?.text).toContain('never use #00000000');
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,38 @@ describe('OpenCodePromptDeliveryLedger', () => {
|
||||||
expect(emptyResult.responseState).toBe('empty_assistant_turn');
|
expect(emptyResult.responseState).toBe('empty_assistant_turn');
|
||||||
expect(emptyResult.attempts).toBe(1);
|
expect(emptyResult.attempts).toBe(1);
|
||||||
|
|
||||||
|
const noAssistant = await store.ensurePending({
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'jack',
|
||||||
|
laneId: 'secondary:opencode:jack',
|
||||||
|
inboxMessageId: 'msg-no-assistant',
|
||||||
|
inboxTimestamp: '2026-04-25T09:59:05.000Z',
|
||||||
|
source: 'watcher',
|
||||||
|
replyRecipient: 'user',
|
||||||
|
payloadHash: 'sha256:no-assistant',
|
||||||
|
now: '2026-04-25T10:00:06.000Z',
|
||||||
|
});
|
||||||
|
const noAssistantResult = await store.applyDeliveryResult({
|
||||||
|
id: noAssistant.id,
|
||||||
|
accepted: true,
|
||||||
|
attempted: true,
|
||||||
|
responseObservation: {
|
||||||
|
state: 'prompt_delivered_no_assistant_message',
|
||||||
|
deliveredUserMessageId: 'oc-user-no-assistant',
|
||||||
|
assistantMessageId: null,
|
||||||
|
toolCallNames: [],
|
||||||
|
visibleMessageToolCallId: null,
|
||||||
|
visibleReplyMessageId: null,
|
||||||
|
visibleReplyCorrelation: null,
|
||||||
|
latestAssistantPreview: null,
|
||||||
|
reason: 'prompt_delivered_no_assistant_message',
|
||||||
|
},
|
||||||
|
now: '2026-04-25T10:00:07.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(noAssistantResult.status).toBe('unanswered');
|
||||||
|
expect(noAssistantResult.responseState).toBe('prompt_delivered_no_assistant_message');
|
||||||
|
|
||||||
const plain = await store.ensurePending({
|
const plain = await store.ensurePending({
|
||||||
teamName: 'team-a',
|
teamName: 'team-a',
|
||||||
memberName: 'jack',
|
memberName: 'jack',
|
||||||
|
|
|
||||||
|
|
@ -531,6 +531,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||||
expect(sentText).toContain('Include source="runtime_delivery"');
|
expect(sentText).toContain('Include source="runtime_delivery"');
|
||||||
expect(sentText).toContain('Include relayOfMessageId="msg-1"');
|
expect(sentText).toContain('Include relayOfMessageId="msg-1"');
|
||||||
expect(sentText).toContain('Action mode for this message: delegate.');
|
expect(sentText).toContain('Action mode for this message: delegate.');
|
||||||
|
expect(sentText).toContain('You must not end this turn empty.');
|
||||||
expect(sentText).toContain('<opencode_delivery_context>');
|
expect(sentText).toContain('<opencode_delivery_context>');
|
||||||
expect(sentText).toContain('"kind":"opencode-delivery-context"');
|
expect(sentText).toContain('"kind":"opencode-delivery-context"');
|
||||||
expect(sentText).toContain('"inboundMessageId":"msg-1"');
|
expect(sentText).toContain('"inboundMessageId":"msg-1"');
|
||||||
|
|
|
||||||
|
|
@ -6955,22 +6955,123 @@ describe('TeamProvisioningService', () => {
|
||||||
delivered: true,
|
delivered: true,
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
});
|
});
|
||||||
expect(sendMessageToMember).toHaveBeenCalledWith(
|
expect(sendMessageToMember).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
runId: 'opencode-run-durable',
|
runId: 'opencode-run-durable',
|
||||||
teamName,
|
teamName,
|
||||||
laneId,
|
laneId,
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
cwd: '/repo',
|
cwd: '/repo',
|
||||||
text: 'hello after restart',
|
text: 'hello after restart',
|
||||||
messageId: 'msg-after-restart',
|
messageId: 'msg-after-restart',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('blocks OpenCode secondary delivery when runtime session exists but bootstrap did not check in', async () => {
|
it('prefers live secondary lane runId over the primary tracked runId for OpenCode member delivery', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const teamName = 'team-a';
|
const teamName = 'team-a';
|
||||||
|
const laneId = 'secondary:opencode:bob';
|
||||||
|
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||||
|
ok: true,
|
||||||
|
providerId: 'opencode',
|
||||||
|
memberName: String(input.memberName),
|
||||||
|
sessionId: 'oc-session-bob',
|
||||||
|
diagnostics: [],
|
||||||
|
}));
|
||||||
|
svc.setRuntimeAdapterRegistry(
|
||||||
|
new TeamRuntimeAdapterRegistry([
|
||||||
|
{
|
||||||
|
providerId: 'opencode',
|
||||||
|
prepare: vi.fn(),
|
||||||
|
launch: vi.fn(),
|
||||||
|
reconcile: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
sendMessageToMember,
|
||||||
|
} as any,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
(svc as any).aliveRunByTeam.set(teamName, 'primary-run');
|
||||||
|
(svc as any).runs.set('primary-run', {
|
||||||
|
runId: 'primary-run',
|
||||||
|
teamName,
|
||||||
|
processKilled: false,
|
||||||
|
cancelRequested: false,
|
||||||
|
progress: { state: 'ready' },
|
||||||
|
request: { providerId: 'codex', cwd: '/repo' },
|
||||||
|
mixedSecondaryLanes: [
|
||||||
|
{
|
||||||
|
laneId,
|
||||||
|
providerId: 'opencode',
|
||||||
|
member: { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
|
||||||
|
runId: 'opencode-run-live',
|
||||||
|
state: 'finished',
|
||||||
|
result: {
|
||||||
|
members: {
|
||||||
|
bob: {
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
sessionId: 'oc-session-bob',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
warnings: [],
|
||||||
|
diagnostics: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
(svc as any).configReader = {
|
||||||
|
getConfig: vi.fn(async () => ({
|
||||||
|
projectPath: '/repo',
|
||||||
|
members: [
|
||||||
|
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
|
||||||
|
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
(svc as any).teamMetaStore = {
|
||||||
|
getMeta: vi.fn(async () => ({
|
||||||
|
launchIdentity: { providerId: 'codex' },
|
||||||
|
providerId: 'codex',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
(svc as any).membersMetaStore = {
|
||||||
|
getMembers: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
name: 'bob',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'opencode/minimax-m2.5-free',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
svc.deliverOpenCodeMemberMessage(teamName, {
|
||||||
|
memberName: 'bob',
|
||||||
|
text: 'hello live lane',
|
||||||
|
messageId: 'msg-live-lane',
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
delivered: true,
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
expect(sendMessageToMember).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
runId: 'opencode-run-live',
|
||||||
|
teamName,
|
||||||
|
laneId,
|
||||||
|
memberName: 'bob',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(sendMessageToMember).not.toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ runId: 'primary-run' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks OpenCode secondary delivery when runtime session exists but bootstrap did not check in', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const teamName = 'team-a';
|
||||||
const laneId = 'secondary:opencode:bob';
|
const laneId = 'secondary:opencode:bob';
|
||||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|
@ -12351,6 +12452,149 @@ describe('TeamProvisioningService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('heals terminal bootstrap-state failures when runtime proof confirms member_briefing', async () => {
|
||||||
|
allowConsoleLogs();
|
||||||
|
const teamName = 'zz-unit-bootstrap-state-runtime-proof-heals';
|
||||||
|
const leadSessionId = 'lead-session';
|
||||||
|
const projectPath = '/Users/test/proj';
|
||||||
|
const acceptedAt = new Date(Date.now() - 90_000).toISOString();
|
||||||
|
const proofAt = new Date(Date.now() - 60_000).toISOString();
|
||||||
|
const failureAt = new Date(Date.now() - 30_000).toISOString();
|
||||||
|
const proofToken = 'proof-token-jack';
|
||||||
|
const runtimeEventsPath = path.join(tempTeamsBase, teamName, 'runtime', 'jack.runtime.jsonl');
|
||||||
|
|
||||||
|
writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']);
|
||||||
|
const configPath = path.join(tempTeamsBase, teamName, 'config.json');
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
|
||||||
|
members: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
config.members = config.members.map((member) =>
|
||||||
|
member.name === 'jack'
|
||||||
|
? {
|
||||||
|
...member,
|
||||||
|
agentId: `jack@${teamName}`,
|
||||||
|
bootstrapExpectedAfter: acceptedAt,
|
||||||
|
bootstrapProofToken: proofToken,
|
||||||
|
bootstrapRuntimeEventsPath: runtimeEventsPath,
|
||||||
|
}
|
||||||
|
: member
|
||||||
|
);
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify(config), 'utf8');
|
||||||
|
writeBootstrapState(
|
||||||
|
teamName,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'jack',
|
||||||
|
status: 'failed',
|
||||||
|
lastAttemptAt: Date.parse(acceptedAt),
|
||||||
|
lastObservedAt: Date.parse(failureAt),
|
||||||
|
failureReason: 'Teammate was registered but did not bootstrap-confirm before timeout.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
failureAt
|
||||||
|
);
|
||||||
|
fs.mkdirSync(path.dirname(runtimeEventsPath), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
runtimeEventsPath,
|
||||||
|
`${JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
type: 'bootstrap_confirmed',
|
||||||
|
timestamp: proofAt,
|
||||||
|
pid: 1234,
|
||||||
|
teamName,
|
||||||
|
agentName: 'jack',
|
||||||
|
agentId: `jack@${teamName}`,
|
||||||
|
source: 'member_briefing_tool_success',
|
||||||
|
bootstrapProofToken: proofToken,
|
||||||
|
})}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const result = await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
|
||||||
|
expect(result.teamLaunchState).toBe('clean_success');
|
||||||
|
expect(result.statuses.jack).toMatchObject({
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
runtimeAlive: true,
|
||||||
|
hardFailure: false,
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not heal bootstrap-state failures from stale runtime proof before spawn acceptance', async () => {
|
||||||
|
allowConsoleLogs();
|
||||||
|
const teamName = 'zz-unit-bootstrap-state-stale-runtime-proof-ignored';
|
||||||
|
const leadSessionId = 'lead-session';
|
||||||
|
const projectPath = '/Users/test/proj';
|
||||||
|
const proofAt = new Date(Date.now() - 120_000).toISOString();
|
||||||
|
const acceptedAt = new Date(Date.now() - 90_000).toISOString();
|
||||||
|
const failureAt = new Date(Date.now() - 30_000).toISOString();
|
||||||
|
const proofToken = 'proof-token-jack';
|
||||||
|
const runtimeEventsPath = path.join(tempTeamsBase, teamName, 'runtime', 'jack.runtime.jsonl');
|
||||||
|
|
||||||
|
writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']);
|
||||||
|
const configPath = path.join(tempTeamsBase, teamName, 'config.json');
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
|
||||||
|
members: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
config.members = config.members.map((member) =>
|
||||||
|
member.name === 'jack'
|
||||||
|
? {
|
||||||
|
...member,
|
||||||
|
agentId: `jack@${teamName}`,
|
||||||
|
bootstrapExpectedAfter: acceptedAt,
|
||||||
|
bootstrapProofToken: proofToken,
|
||||||
|
bootstrapRuntimeEventsPath: runtimeEventsPath,
|
||||||
|
}
|
||||||
|
: member
|
||||||
|
);
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify(config), 'utf8');
|
||||||
|
writeBootstrapState(
|
||||||
|
teamName,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'jack',
|
||||||
|
status: 'failed',
|
||||||
|
lastAttemptAt: Date.parse(acceptedAt),
|
||||||
|
lastObservedAt: Date.parse(failureAt),
|
||||||
|
failureReason: 'Teammate was registered but did not bootstrap-confirm before timeout.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
failureAt
|
||||||
|
);
|
||||||
|
fs.mkdirSync(path.dirname(runtimeEventsPath), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
runtimeEventsPath,
|
||||||
|
`${JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
type: 'bootstrap_confirmed',
|
||||||
|
timestamp: proofAt,
|
||||||
|
pid: 1234,
|
||||||
|
teamName,
|
||||||
|
agentName: 'jack',
|
||||||
|
agentId: `jack@${teamName}`,
|
||||||
|
source: 'member_briefing_tool_success',
|
||||||
|
bootstrapProofToken: proofToken,
|
||||||
|
})}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const result = await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
|
||||||
|
expect(result.teamLaunchState).toBe('partial_failure');
|
||||||
|
expect(result.statuses.jack).toMatchObject({
|
||||||
|
status: 'error',
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
runtimeAlive: false,
|
||||||
|
hardFailure: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('does not heal bootstrap-state failures from stale pre-launch transcript success', async () => {
|
it('does not heal bootstrap-state failures from stale pre-launch transcript success', async () => {
|
||||||
allowConsoleLogs();
|
allowConsoleLogs();
|
||||||
const teamName = 'zz-unit-bootstrap-state-stale-transcript-ignored';
|
const teamName = 'zz-unit-bootstrap-state-stale-transcript-ignored';
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ describe('buildTeamRuntimeDisplayRows', () => {
|
||||||
memberName: 'alice',
|
memberName: 'alice',
|
||||||
state: 'degraded',
|
state: 'degraded',
|
||||||
source: 'mixed',
|
source: 'mixed',
|
||||||
stateReason: 'Bootstrap command failed',
|
stateReason: 'Bootstrap command failed. Process is still alive.',
|
||||||
actionsAllowed: false,
|
actionsAllowed: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -27,4 +27,29 @@ describe('openCodeRuntimeDeliveryDiagnostics', () => {
|
||||||
reason: 'empty_assistant_turn',
|
reason: 'empty_assistant_turn',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('surfaces prompt delivery with no recorded assistant turn separately', () => {
|
||||||
|
const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({
|
||||||
|
deliveredToInbox: true,
|
||||||
|
messageId: 'msg-no-assistant',
|
||||||
|
runtimeDelivery: {
|
||||||
|
providerId: 'opencode',
|
||||||
|
attempted: true,
|
||||||
|
delivered: false,
|
||||||
|
responsePending: false,
|
||||||
|
responseState: 'prompt_delivered_no_assistant_message',
|
||||||
|
ledgerStatus: 'failed_terminal',
|
||||||
|
reason: 'prompt_delivered_no_assistant_message',
|
||||||
|
diagnostics: ['prompt_delivered_no_assistant_message'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(diagnostics.warning).toBe(
|
||||||
|
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode accepted the prompt, but no assistant turn was recorded.'
|
||||||
|
);
|
||||||
|
expect(diagnostics.debugDetails).toMatchObject({
|
||||||
|
responseState: 'prompt_delivered_no_assistant_message',
|
||||||
|
reason: 'prompt_delivered_no_assistant_message',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue