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;
|
||||
providerId?: string;
|
||||
cwd?: string;
|
||||
bootstrapExpectedAfter?: string;
|
||||
bootstrapProofToken?: string;
|
||||
bootstrapRuntimeEventsPath?: string;
|
||||
runtimePid?: number;
|
||||
runtimeSessionId?: string;
|
||||
}
|
||||
|
|
@ -338,6 +341,40 @@ interface LaunchStateWriteResult {
|
|||
|
||||
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 =
|
||||
| {
|
||||
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 {
|
||||
const text = error instanceof Error ? error.message : String(error ?? '');
|
||||
return (
|
||||
|
|
@ -2673,7 +2718,9 @@ function isAutoClearableLaunchFailureReason(reason?: string): boolean {
|
|||
isConfigRegistrationFailureReason(reason) ||
|
||||
isRegisteredRuntimeMetadataFailureReason(reason) ||
|
||||
isOpenCodeBridgeLaunchFailureReason(reason) ||
|
||||
isBootstrapMcpResourceReadFailureReason(reason)
|
||||
isBootstrapMcpResourceReadFailureReason(reason) ||
|
||||
isBootstrapCheckInTimeoutFailureReason(reason) ||
|
||||
isBootstrapInstructionPromptFailureReason(reason)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -6605,9 +6652,34 @@ export class TeamProvisioningService {
|
|||
if (state === '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';
|
||||
}
|
||||
|
||||
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: {
|
||||
ledgerRecord: OpenCodePromptDeliveryLedgerRecord;
|
||||
visibleReply?: OpenCodeVisibleReplyProof | null;
|
||||
|
|
@ -7494,7 +7566,7 @@ export class TeamProvisioningService {
|
|||
lane.member.name.trim().toLowerCase() === normalizedMemberName.toLowerCase()
|
||||
);
|
||||
trackedSecondaryLanePresent = liveLane != null;
|
||||
liveSecondaryLaneRunId = liveLane ? trackedRunId : null;
|
||||
liveSecondaryLaneRunId = liveLane?.runId?.trim() || null;
|
||||
const liveLaneMember = liveLane
|
||||
? (liveLane.result?.members?.[canonicalMemberName] ??
|
||||
liveLane.result?.members?.[liveLane.member.name])
|
||||
|
|
@ -7639,11 +7711,14 @@ export class TeamProvisioningService {
|
|||
runtimePid: result.runtimePid,
|
||||
reason: 'opencode_delivery_runtime_pid_observed',
|
||||
});
|
||||
const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation(
|
||||
result.responseObservation
|
||||
);
|
||||
return {
|
||||
delivered: result.ok,
|
||||
accepted: result.ok,
|
||||
responsePending: false,
|
||||
responseState: result.responseObservation?.state,
|
||||
responseState: responseObservation?.state,
|
||||
...(result.ok
|
||||
? {}
|
||||
: { reason: result.diagnostics[0] ?? 'opencode_message_delivery_failed' }),
|
||||
|
|
@ -7880,9 +7955,12 @@ export class TeamProvisioningService {
|
|||
runtimePid: observed.runtimePid,
|
||||
reason: 'opencode_delivery_observe_runtime_pid_observed',
|
||||
});
|
||||
const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation(
|
||||
observed.responseObservation
|
||||
);
|
||||
ledgerRecord = await ledger.applyObservation({
|
||||
id: ledgerRecord.id,
|
||||
responseObservation: observed.responseObservation ?? {
|
||||
responseObservation: responseObservation ?? {
|
||||
state: observed.ok ? 'not_observed' : 'reconcile_failed',
|
||||
deliveredUserMessageId: null,
|
||||
assistantMessageId: null,
|
||||
|
|
@ -8004,16 +8082,19 @@ export class TeamProvisioningService {
|
|||
runtimePid: result.runtimePid,
|
||||
reason: 'opencode_delivery_runtime_pid_observed',
|
||||
});
|
||||
const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation(
|
||||
result.responseObservation
|
||||
);
|
||||
if (ledgerRecord && ledger) {
|
||||
ledgerRecord = await ledger.applyDeliveryResult({
|
||||
id: ledgerRecord.id,
|
||||
accepted: result.ok,
|
||||
attempted: true,
|
||||
responseObservation: result.responseObservation,
|
||||
responseObservation,
|
||||
sessionId: result.sessionId,
|
||||
prePromptCursor: result.prePromptCursor,
|
||||
diagnostics: result.diagnostics,
|
||||
reason: result.ok ? result.responseObservation?.reason : result.diagnostics[0],
|
||||
reason: result.ok ? responseObservation?.reason : result.diagnostics[0],
|
||||
now: nowIso(),
|
||||
});
|
||||
let proof = await this.applyOpenCodeVisibleDestinationProof({
|
||||
|
|
@ -8044,7 +8125,7 @@ export class TeamProvisioningService {
|
|||
{ 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
|
||||
? await this.findOpenCodeVisibleReplyByRelayOfMessageId({
|
||||
teamName,
|
||||
|
|
@ -8139,15 +8220,15 @@ export class TeamProvisioningService {
|
|||
}
|
||||
const responseVisibleReplyMessageId =
|
||||
ledgerRecord?.visibleReplyMessageId ??
|
||||
result.responseObservation?.visibleReplyMessageId ??
|
||||
responseObservation?.visibleReplyMessageId ??
|
||||
undefined;
|
||||
const responseVisibleReplyCorrelation =
|
||||
ledgerRecord?.visibleReplyCorrelation ??
|
||||
result.responseObservation?.visibleReplyCorrelation ??
|
||||
responseObservation?.visibleReplyCorrelation ??
|
||||
undefined;
|
||||
const acceptanceUnknown = Boolean(ledgerRecord?.acceptanceUnknown && !result.ok);
|
||||
const responsePending =
|
||||
acceptanceUnknown || (result.ok && Boolean(ledgerRecord || result.responseObservation))
|
||||
acceptanceUnknown || (result.ok && Boolean(ledgerRecord || responseObservation))
|
||||
? !readAllowed
|
||||
: false;
|
||||
const pendingReason =
|
||||
|
|
@ -8162,8 +8243,8 @@ export class TeamProvisioningService {
|
|||
: result.diagnostics;
|
||||
return {
|
||||
delivered: result.ok || acceptanceUnknown,
|
||||
...(ledgerRecord || result.responseObservation ? { accepted: result.ok } : {}),
|
||||
...(ledgerRecord || result.responseObservation ? { responsePending } : {}),
|
||||
...(ledgerRecord || responseObservation ? { accepted: result.ok } : {}),
|
||||
...(ledgerRecord || responseObservation ? { responsePending } : {}),
|
||||
...(acceptanceUnknown ? { acceptanceUnknown: true } : {}),
|
||||
...(ledgerRecord
|
||||
? {
|
||||
|
|
@ -11521,7 +11602,8 @@ export class TeamProvisioningService {
|
|||
private confirmMemberSpawnStatusFromTranscript(
|
||||
run: ProvisioningRun,
|
||||
memberName: string,
|
||||
observedAt: string
|
||||
observedAt: string,
|
||||
source: 'transcript' | 'runtime-proof' = 'transcript'
|
||||
): void {
|
||||
const prev = run.memberSpawnStatuses.get(memberName) ?? createInitialMemberSpawnStatusEntry();
|
||||
const updatedAt = nowIso();
|
||||
|
|
@ -11530,7 +11612,7 @@ export class TeamProvisioningService {
|
|||
status: 'online',
|
||||
updatedAt,
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: prev.runtimeAlive === true,
|
||||
runtimeAlive: source === 'runtime-proof' ? true : prev.runtimeAlive === true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
bootstrapStalled: undefined,
|
||||
|
|
@ -11564,7 +11646,13 @@ export class TeamProvisioningService {
|
|||
run.memberSpawnStatuses.set(memberName, next);
|
||||
run.pendingMemberRestarts?.delete(memberName);
|
||||
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;
|
||||
this.emitMemberSpawnChange(run, memberName);
|
||||
if (run.isLaunch) {
|
||||
|
|
@ -13914,12 +14002,26 @@ export class TeamProvisioningService {
|
|||
(current.launchState === 'failed_to_start' && !canClearFailedBootstrap) ||
|
||||
current.launchState === 'confirmed_alive' ||
|
||||
current.bootstrapConfirmed === true ||
|
||||
current.agentToolAccepted !== true
|
||||
(current.agentToolAccepted !== true && !canClearFailedBootstrap)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const acceptedAtMs =
|
||||
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(
|
||||
run.teamName,
|
||||
memberName,
|
||||
|
|
@ -22658,6 +22760,19 @@ export class TeamProvisioningService {
|
|||
}
|
||||
const acceptedAtMs =
|
||||
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(
|
||||
snapshot.teamName,
|
||||
expected,
|
||||
|
|
@ -22673,6 +22788,159 @@ export class TeamProvisioningService {
|
|||
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(
|
||||
snapshot: PersistedTeamLaunchSnapshot | null
|
||||
): Promise<PersistedTeamLaunchSnapshot | null> {
|
||||
|
|
@ -22691,15 +22959,30 @@ export class TeamProvisioningService {
|
|||
) {
|
||||
continue;
|
||||
}
|
||||
const failureReason = current.hardFailureReason ?? current.runtimeDiagnostic;
|
||||
const canClearFailedBootstrap =
|
||||
current.launchState !== 'failed_to_start' ||
|
||||
isAutoClearableLaunchFailureReason(failureReason);
|
||||
if (!canClearFailedBootstrap) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const acceptedAtMs =
|
||||
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
|
||||
const runtimeProofObservedAt = await this.findBootstrapRuntimeProofObservedAt(
|
||||
snapshot.teamName,
|
||||
expected,
|
||||
current
|
||||
);
|
||||
const transcriptOutcome = await this.findBootstrapTranscriptOutcome(
|
||||
snapshot.teamName,
|
||||
expected,
|
||||
Number.isFinite(acceptedAtMs) ? acceptedAtMs : null
|
||||
);
|
||||
if (transcriptOutcome?.kind !== 'success') {
|
||||
const observedAt =
|
||||
runtimeProofObservedAt ??
|
||||
(transcriptOutcome?.kind === 'success' ? transcriptOutcome.observedAt : null);
|
||||
if (!observedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -22707,9 +22990,13 @@ export class TeamProvisioningService {
|
|||
...current,
|
||||
agentToolAccepted: true,
|
||||
bootstrapConfirmed: true,
|
||||
runtimeAlive: runtimeProofObservedAt ? true : current.runtimeAlive === true,
|
||||
hardFailure: false,
|
||||
hardFailureReason: undefined,
|
||||
lastHeartbeatAt: current.lastHeartbeatAt ?? transcriptOutcome.observedAt,
|
||||
lastHeartbeatAt: current.lastHeartbeatAt ?? observedAt,
|
||||
lastRuntimeAliveAt: runtimeProofObservedAt
|
||||
? (current.lastRuntimeAliveAt ?? observedAt)
|
||||
: current.lastRuntimeAliveAt,
|
||||
lastEvaluatedAt: nowIso(),
|
||||
sources: {
|
||||
...(current.sources ?? {}),
|
||||
|
|
@ -22997,6 +23284,8 @@ export class TeamProvisioningService {
|
|||
: null;
|
||||
const acceptedAtMs =
|
||||
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
|
||||
const initialFailureReason = current.hardFailureReason ?? current.runtimeDiagnostic;
|
||||
const hadAutoClearableFailure = isAutoClearableLaunchFailureReason(initialFailureReason);
|
||||
current.runtimeAlive = observedRuntimeAlive;
|
||||
current.lastRuntimeAliveAt = observedRuntimeAlive ? now : current.lastRuntimeAliveAt;
|
||||
current.livenessKind = runtimeMetadata?.[1].livenessKind;
|
||||
|
|
@ -23019,7 +23308,7 @@ export class TeamProvisioningService {
|
|||
const currentProvesSpawnAcceptance =
|
||||
current.agentToolAccepted === true || typeof current.firstSpawnAcceptedAt === 'string';
|
||||
if (
|
||||
isAutoClearableLaunchFailureReason(current.hardFailureReason) &&
|
||||
hadAutoClearableFailure &&
|
||||
(bootstrapProvesSpawnAcceptance || currentProvesSpawnAcceptance)
|
||||
) {
|
||||
current.hardFailure = false;
|
||||
|
|
@ -23049,15 +23338,34 @@ export class TeamProvisioningService {
|
|||
current.hardFailure = false;
|
||||
current.hardFailureReason = undefined;
|
||||
}
|
||||
if (!current.bootstrapConfirmed) {
|
||||
const transcriptOutcome = await this.findBootstrapTranscriptOutcome(
|
||||
teamName,
|
||||
expected,
|
||||
Number.isFinite(acceptedAtMs) ? acceptedAtMs : null
|
||||
);
|
||||
if (transcriptOutcome?.kind === 'success' && !isOpenCodeSecondaryLaneMember) {
|
||||
const canApplyBootstrapSuccess =
|
||||
!heartbeatReason &&
|
||||
(current.launchState !== 'failed_to_start' ||
|
||||
hadAutoClearableFailure ||
|
||||
isAutoClearableLaunchFailureReason(
|
||||
current.hardFailureReason ?? current.runtimeDiagnostic
|
||||
));
|
||||
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.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.hardFailureReason = undefined;
|
||||
if (current.sources) {
|
||||
|
|
|
|||
|
|
@ -177,6 +177,7 @@ export type OpenCodeDeliveryResponseState =
|
|||
| 'permission_blocked'
|
||||
| 'tool_error'
|
||||
| 'empty_assistant_turn'
|
||||
| 'prompt_delivered_no_assistant_message'
|
||||
| 'session_stale'
|
||||
| 'session_error'
|
||||
| 'reconcile_failed';
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ const OPENCODE_DELIVERY_RESPONSE_STATES = new Set<OpenCodeDeliveryResponseState>
|
|||
'permission_blocked',
|
||||
'tool_error',
|
||||
'empty_assistant_turn',
|
||||
'prompt_delivered_no_assistant_message',
|
||||
'session_stale',
|
||||
'session_error',
|
||||
'reconcile_failed',
|
||||
|
|
@ -274,7 +275,7 @@ export class OpenCodePromptDeliveryLedgerStore {
|
|||
const responseState =
|
||||
observation?.state ?? (input.accepted ? record.responseState : 'not_observed');
|
||||
const responded = isOpenCodePromptResponseStateResponded(responseState);
|
||||
const unanswered = responseState === 'empty_assistant_turn';
|
||||
const unanswered = isOpenCodePromptDeliveryUnansweredResponseState(responseState);
|
||||
return {
|
||||
...record,
|
||||
status: input.accepted
|
||||
|
|
@ -321,7 +322,9 @@ export class OpenCodePromptDeliveryLedgerStore {
|
|||
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
|
||||
return await this.updateExisting(input.id, (record) => {
|
||||
const responded = isOpenCodePromptResponseStateResponded(input.responseObservation.state);
|
||||
const unanswered = input.responseObservation.state === 'empty_assistant_turn';
|
||||
const unanswered = isOpenCodePromptDeliveryUnansweredResponseState(
|
||||
input.responseObservation.state
|
||||
);
|
||||
return {
|
||||
...record,
|
||||
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(
|
||||
record: OpenCodePromptDeliveryLedgerRecord,
|
||||
nowMs: number = Date.now()
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ export function isOpenCodePromptDeliveryRetryableResponseState(
|
|||
): boolean {
|
||||
return (
|
||||
state === 'empty_assistant_turn' ||
|
||||
state === 'prompt_delivered_no_assistant_message' ||
|
||||
state === 'tool_error' ||
|
||||
state === 'reconcile_failed' ||
|
||||
state === 'not_observed' ||
|
||||
|
|
|
|||
|
|
@ -798,6 +798,7 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
|
|||
? `Include relayOfMessageId="${input.messageId}" in that message_send call.`
|
||||
: null,
|
||||
'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 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.',
|
||||
|
|
|
|||
|
|
@ -345,6 +345,33 @@ interface LeadContextBridgeProps {
|
|||
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.
|
||||
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) => {
|
||||
setSelectedTask(task);
|
||||
}, []);
|
||||
const handleOpenMessagePanelTask = useCallback(
|
||||
(task: TeamTaskWithKanban) => {
|
||||
handleOpenTaskById(task.id);
|
||||
},
|
||||
[handleOpenTaskById]
|
||||
);
|
||||
|
||||
const handleTaskIdClick = useCallback(
|
||||
(taskId: string) => {
|
||||
|
|
@ -2017,21 +2047,22 @@ export const TeamDetailView = memo(function TeamDetailView({
|
|||
})();
|
||||
};
|
||||
|
||||
const messagesPanelTasks = useStableMessagesPanelTasks(data?.tasks);
|
||||
|
||||
const sharedMessagesPanelProps = useMemo<SharedTeamMessagesPanelProps>(
|
||||
() => ({
|
||||
teamName,
|
||||
onPositionChange: changeMessagesPanelMode,
|
||||
mountPoint: messagesPanelMountPoint,
|
||||
members: activeMembers,
|
||||
tasks: data?.tasks ?? [],
|
||||
tasks: messagesPanelTasks,
|
||||
isTeamAlive: data?.isAlive,
|
||||
timeWindow,
|
||||
teamSessionIds,
|
||||
currentLeadSessionId: data?.config.leadSessionId,
|
||||
pendingRepliesByMember,
|
||||
onPendingReplyChange: setPendingRepliesByMember,
|
||||
onMemberClick: handleSelectMember,
|
||||
onTaskClick: handleOpenTask,
|
||||
onTaskClick: handleOpenMessagePanelTask,
|
||||
onCreateTaskFromMessage: handleCreateTaskFromMessage,
|
||||
onReplyToMessage: handleReplyToMessage,
|
||||
onRestartTeam: handleRestartTeam,
|
||||
|
|
@ -2042,17 +2073,16 @@ export const TeamDetailView = memo(function TeamDetailView({
|
|||
activeMembers,
|
||||
data?.config.leadSessionId,
|
||||
data?.isAlive,
|
||||
data?.tasks,
|
||||
handleCreateTaskFromMessage,
|
||||
handleOpenTask,
|
||||
handleOpenMessagePanelTask,
|
||||
handleReplyToMessage,
|
||||
handleRestartTeam,
|
||||
handleSelectMember,
|
||||
handleTaskIdClick,
|
||||
messagesPanelTasks,
|
||||
messagesPanelMountPoint,
|
||||
pendingRepliesByMember,
|
||||
teamName,
|
||||
teamSessionIds,
|
||||
timeWindow,
|
||||
changeMessagesPanelMode,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
type ComponentProps,
|
||||
memo,
|
||||
type RefObject,
|
||||
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({
|
||||
teamName,
|
||||
position,
|
||||
|
|
@ -282,8 +386,10 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
await loadOlderTeamMessages(teamName);
|
||||
}, [loadOlderTeamMessages, messagesState, teamName]);
|
||||
|
||||
const messagesLoading =
|
||||
(messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false);
|
||||
const handleLoadOlderMessagesClick = useCallback(() => {
|
||||
void loadOlderMessages();
|
||||
}, [loadOlderMessages]);
|
||||
|
||||
const loadingOlderMessages = messagesState?.loadingOlder ?? false;
|
||||
const hasMore = messagesState?.hasMore ?? false;
|
||||
const effectiveMessages = messages;
|
||||
|
|
@ -723,6 +829,99 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
);
|
||||
}, [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) ----
|
||||
const searchAndFilterControls = (
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -785,83 +984,9 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
|
||||
const messagesContent = (
|
||||
<div className="pb-14">
|
||||
<MessageComposer
|
||||
teamName={teamName}
|
||||
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="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}
|
||||
/>
|
||||
{defaultComposerSection}
|
||||
{inlineStatusSection}
|
||||
{timelineSection}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -972,84 +1097,10 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
onScroll={(e) => setMessagesScrollTop(e.currentTarget.scrollTop)}
|
||||
>
|
||||
<div className="pl-3">
|
||||
<MessageComposer
|
||||
teamName={teamName}
|
||||
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}
|
||||
/>{' '}
|
||||
{defaultComposerSection}
|
||||
{sidebarStatusSection}
|
||||
</div>
|
||||
<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}
|
||||
/>
|
||||
{timelineSection}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1256,91 +1307,10 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
{searchAndFilterControls}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3">
|
||||
<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 className="p-3">{compactComposerSection}</div>
|
||||
</div>
|
||||
<div className="shrink-0 px-3 pt-2">
|
||||
<StatusBlock
|
||||
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}
|
||||
/>
|
||||
<div className="shrink-0 px-3 pt-2">{inlineStatusSection}</div>
|
||||
<div className="flex-1 px-3 pb-4 pt-2">{timelineSection}</div>
|
||||
</Sheet.Content>
|
||||
)}
|
||||
</Sheet.Container>
|
||||
|
|
|
|||
|
|
@ -66,12 +66,12 @@ export const StatusBlock = ({
|
|||
return hasActiveTasks;
|
||||
}, [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(() => {
|
||||
if (!hasItems) return;
|
||||
if (!hasPendingReplies) return;
|
||||
const id = window.setInterval(() => setNowMs(Date.now()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [hasItems]);
|
||||
}, [hasPendingReplies]);
|
||||
|
||||
if (!hasItems) return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -135,8 +135,11 @@ function buildRuntimeBackedDisplayRow(
|
|||
const hasErrorDiagnostic = runtime.runtimeDiagnosticSeverity === 'error';
|
||||
const spawnDegradation = getSpawnDegradation(spawn);
|
||||
const state = getRuntimeBackedState(runtime, hasErrorDiagnostic, spawnDegradation != null);
|
||||
const degradedReason = spawnDegradation
|
||||
? withLiveProcessContext(spawnDegradation.reason, runtime)
|
||||
: undefined;
|
||||
const stateReason =
|
||||
spawnDegradation?.reason ??
|
||||
degradedReason ??
|
||||
runtime.runtimeDiagnostic ??
|
||||
(runtime.alive === true ? 'Runtime heartbeat is alive' : 'Runtime heartbeat is not alive');
|
||||
|
||||
|
|
@ -151,7 +154,10 @@ function buildRuntimeBackedDisplayRow(
|
|||
laneId: runtime.laneId,
|
||||
laneKind: runtime.laneKind,
|
||||
runtimeModel: runtime.runtimeModel,
|
||||
diagnostic: spawnDegradation?.diagnostic ?? runtime.runtimeDiagnostic,
|
||||
diagnostic:
|
||||
spawnDegradation && degradedReason
|
||||
? withLiveProcessContext(spawnDegradation.diagnostic ?? degradedReason, runtime)
|
||||
: runtime.runtimeDiagnostic,
|
||||
diagnosticSeverity: spawnDegradation?.diagnosticSeverity ?? runtime.runtimeDiagnosticSeverity,
|
||||
pidLabel: formatRuntimePidLabel(runtime),
|
||||
actionsAllowed: false,
|
||||
|
|
@ -213,6 +219,13 @@ function getRuntimeBackedState(
|
|||
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(
|
||||
memberName: string,
|
||||
spawn: MemberSpawnStatusEntry
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | unde
|
|||
if (normalized === '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 '';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -712,6 +712,7 @@ export interface SendMessageResult {
|
|||
| 'permission_blocked'
|
||||
| 'tool_error'
|
||||
| 'empty_assistant_turn'
|
||||
| 'prompt_delivered_no_assistant_message'
|
||||
| 'session_stale'
|
||||
| 'session_error'
|
||||
| '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 relayOfMessageId="semantic-direct-');
|
||||
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('Do not use SendMessage or runtime_deliver_message');
|
||||
expect(directCommand?.text).toContain('never use #00000000');
|
||||
|
|
|
|||
|
|
@ -255,6 +255,38 @@ describe('OpenCodePromptDeliveryLedger', () => {
|
|||
expect(emptyResult.responseState).toBe('empty_assistant_turn');
|
||||
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({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
|
|
|
|||
|
|
@ -531,6 +531,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
expect(sentText).toContain('Include source="runtime_delivery"');
|
||||
expect(sentText).toContain('Include relayOfMessageId="msg-1"');
|
||||
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('"kind":"opencode-delivery-context"');
|
||||
expect(sentText).toContain('"inboundMessageId":"msg-1"');
|
||||
|
|
|
|||
|
|
@ -6955,22 +6955,123 @@ describe('TeamProvisioningService', () => {
|
|||
delivered: true,
|
||||
diagnostics: [],
|
||||
});
|
||||
expect(sendMessageToMember).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runId: 'opencode-run-durable',
|
||||
teamName,
|
||||
laneId,
|
||||
expect(sendMessageToMember).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runId: 'opencode-run-durable',
|
||||
teamName,
|
||||
laneId,
|
||||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
text: 'hello after restart',
|
||||
messageId: 'msg-after-restart',
|
||||
})
|
||||
);
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks OpenCode secondary delivery when runtime session exists but bootstrap did not check in', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const teamName = 'team-a';
|
||||
it('prefers live secondary lane runId over the primary tracked runId for OpenCode member delivery', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
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 sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
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 () => {
|
||||
allowConsoleLogs();
|
||||
const teamName = 'zz-unit-bootstrap-state-stale-transcript-ignored';
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ describe('buildTeamRuntimeDisplayRows', () => {
|
|||
memberName: 'alice',
|
||||
state: 'degraded',
|
||||
source: 'mixed',
|
||||
stateReason: 'Bootstrap command failed',
|
||||
stateReason: 'Bootstrap command failed. Process is still alive.',
|
||||
actionsAllowed: false,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,4 +27,29 @@ describe('openCodeRuntimeDeliveryDiagnostics', () => {
|
|||
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