mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 07:55:47 +01:00
## Thinking Path > - Paperclip manages real work by AI agents. > - Daily QA must verify that work on the staging deployment. > - Classic Agent Chat coverage already exercises hiring and teammate reuse. > - The deployment journey should share that behavior and its ownership oracle. > - Saved documents, exact callback replies and physical cleanup establish success. > - The corrected journey and complete daily suite now pass on QA. ## Linked Issues or Issue Description **Subsystem affected** Product E2E deployment journeys and their shared browser controls. **Problem or motivation** QA must prove that one newly hired teammate completes two different tasks and returns both saved results to the lead conversation. Names, chat claims and successful-run counts alone cannot establish authorship or completion. **Proposed solution** Reuse the classic hiring oracle and production composer. Verify immutable creation audits, initial assignment, current project and ownership, saved document revisions, exact native authors, durable callbacks and their own task links after reload. Bound execution to five lead turns and two child turns. **Alternatives considered** A separate QA implementation would duplicate the classic journey. Name matching or later reassignment would accept broken initial ownership. Links elsewhere in the conversation cannot substitute for either callback's own result link. **Roadmap alignment** This verifies existing features. Related merged repairs: #18389, #18465, #18459, #18463, #18502, #18503, #18504, #18506, #18507 and #18514. Separate dependency #18517 repairs file preservation after lifecycle cancellation and is merged as Core `050365e`, which also contains #18514. This primary PR now differs from that protected mainline in only 16 unchanged H harness/documentation paths; it adds no application production delta. The measured QA deployment serves Core `050365e`, with its matching bundled provider pack and unchanged approved model/ROM. ## What Changed - Add the shared `hire-delegate-reuse` journey, catalog, types, dispatch, launch fingerprints, journey-feature entries and documentation. Hire one teammate, delegate a checklist, then reuse the same teammate for a saved review. Require seven canonical native runs: five lead and two child turns. - Require exact company, lead, chat and native creation receipts. Both tasks must be created for the intended teammate using immutable creation-audit assignee fields. Independently verify current project and ownership. Creation-time project is not recorded in that audit and is not claimed by this assertion. - Verify both current document revisions belong to the exact child runs and preserve the original checklist. - Use ordinary production rich-editor agent/project autocomplete controls. Verify exact reference chips, then send once. Preserve the shared default sender. - Require each durable, run-owned callback reply to contain its own exact completed-task link. Collect links through the actual production Markdown renderer and issue-reference decoration, then apply the existing company-scoped route authority. An unrelated table or code fence cannot hide a rendered link; foreign, wrong-task and code-only links remain failures. - Preserve approved connection, account, grant and responsible-user attribution. Verify the child's independent installation and retain the immutable binding snapshot. Do not copy credentials or broaden grants. - Record ownership before later observations can fail. Retry only typed transient observation GET failures. Preserve authority and cleanup failures. - Retain all 14 daily journeys, the model matrix, deadlines, seven-run cap and 500-cent aggregate allowance split 300/200. ## Verification - **Current composed head:** `afa3f5552ab0c1a9f0ee7327dfa5665d57cc7bcb`. It normally merges mainline `1e8e322` and exact #18517 source `adb1d4e`, retaining the intermediate `b2d9eb4` and `6fd1042` ancestry. The only merge conflict was the Playwright support registration; it is resolved to the exact previously qualified union. All 16 H harness/documentation paths are byte-identical to `5c0830f`; all four #18517 paths are byte-identical to `adb1d4e`. Every other tracked path matches actual protected mainline `050365e`. A clean merge preview of `050365e` and this head equals the current head tree, so no additional source commit is needed. GitHub's three-dot display still lists the four already merged helper files because #18517 used a squash merge; those blobs equal protected mainline and add no net application change. No journey, oracle, cap or model change accompanies this composition. A final clean merge preview on intervening mainline `006d5691a6245d5a710e25ba3df6d609754e163a` preserves all unrelated changes and adds exactly the same 16 measured H blobs, with no net App production delta. That later mainline is not represented as the AppCore measured by the QA run. - **Local qualification:** 149/149 focused tests across six suites, including the report-catalog case, and 11/11 real Chromium checks passed on `b2d9eb4`. All H modules, browser inputs and parser/package dependencies are byte-identical at `afa3f55`; those unchanged suites were not repeated. Current-source transitive types, maps, module boundaries and diff checks pass. Official Node24 QA runtime imports pass again using exact public `afa3f55` and private `8671fa9`, with a clean credential-free environment and network disabled. The initial local report-catalog failure is retained: the child `tsx` process was denied its local IPC socket before importing the report or writing an artifact. The same source and selected case pass after correcting only sandbox IPC permission; no source workaround or deselection was used. - **Current canonical CI and reviews pass.** [CI 41275109691](https://github.com/paperclipai/paperclip/actions/runs/41275109691) completed successfully at exact `afa3f55`: all 59 contexts are terminal and clean (55 success, three skipped, one neutral). Fresh exact-head Greptile and official Paperclip reviews both scored 5/5; all 27 review threads are resolved with pagination complete. The official review is static and its check is neutral; it did not execute tests or live QA. A separate exact-head independent composition assessment found no actionable issues at 4/5 confidence and performed no live actions. **Fresh QA qualification passed: all 14 daily journeys, including the corrected H7, passed against deployed Core `050365e`.** Prior check conclusions and review scores retain their actual heads. The previous `5c0830f` completed all 59 contexts cleanly, actual exact-head Greptile and official Paperclip reviews both scored 5/5, and all 27 review threads were resolved. Its separate independent journey-source review at `2b803f08` remained 4/5 with a unit-helper authorship caveat. Intermediate `b2d9eb4` received an actual exact-head Greptile 5/5 with zero open findings, then was updated to compose the valid dependency review fix before live QA. That score and its pending CI are not attributed to `afa3f55`. - Existing causal controls remain unchanged: invalid immutable assignee, either missing callback link, foreign/code/wrong-task links and an earlier handoff fail against the relevant prior source. Renderer controls prove that tables and fenced code elsewhere in a valid callback do not hide its actual task link. Both composer and document-navigation browser registrations remain present. - #18503 merged after its actual QA document case passed four canonical checks and five phases, positively established zero native work, and completed cleanup. Its measured files are unchanged in this composition. #18506 and #18507 merged after their own checks; their causal worker-settlement controls remain intact. - #18514 is merged as `1e8e322`. It reproduces the selected-task metadata-only-project startup failure against deployed `756599e` and passes 19 real-PostgreSQL selected controls after repair. Both direct and assembled baseline controls fail before it. Real source, explicit selection, policy and workspace-isolation guards remain enforced. It is included in measured Core `050365e`; fresh H7 reached and completed both delegated native tasks. - #18517 initial source `6fd1042` passed 320/320 focused and sibling Stop/recovery tests, including 20 preservation controls. Its actual official review was 4/5 and found a valid follow-up: the new live-controller guard should wait for actual lease expiry rather than repeat locked checks every 30 seconds. Both an already-live lease and a real under-lock renewal reproduce that issue against `6fd1042`. Corrected source `adb1d4e` passes all 22 preservation controls and 124 directly affected tests across three suites, exact-head server build/stamp, typecheck and structural checks. It defers before locking and rechecks renewed expiry afterward, preserving the transactional owner guard. The old score/failure and successful owner tests remain attributed to their actual sources. Independent exact-head review of `adb1d4e` found no actionable issues at 4/5 confidence. This is local/source evidence, not physical QA recovery. Its current [CI 41274963476](https://github.com/paperclipai/paperclip/actions/runs/41274963476) completed successfully with all 59 contexts clean; both actual current reviews are 5/5 with zero unresolved threads. It merged normally as `050365e` after those gates. QA now runs that image. The historical allocation is positively stopped and retained; its files-only export remains a separately tracked repair, not a passing-test assertion. - The historical [first H7 pass](https://pages.paperclip.ing/evals-qa-smoke-41226065043-1/) remains attributed to public `5d3f143`, private `dabad62` and App `b7f1c37`: all checks and five phases passed, seven native runs succeeded, both saved documents and exact callbacks were verified, and physical cleanup completed. An optional runtime-metadata receipt was inconclusive and was not grade authority. Material failed attempts remain failed: | Attempt | Actual result and correction | | --- | --- | | [41231853638](https://pages.paperclip.ing/evals-qa-smoke-41231853638-1/) | 13 passed; hiring omitted initial teammate/project assignment and started extra work. The production composer repair retains the ownership negative. Later recovery stopped physical work without changing the failed grade. | | [41238711372](https://pages.paperclip.ing/evals-qa-smoke-41238711372-1/) | All seven native runs succeeded, but the final callback had no durable chat reply. Cleanup passed. #18502 repairs premature completion-outbox retargeting; the fresh measured H7 now verifies both durable, run-owned callback replies and their own task links. | | [41253428446](https://pages.paperclip.ing/evals-qa-smoke-41253428446-1/) | 12 passed; hiring rejected a mentioned metadata-only project before delegation, and document reopening hit an immediate-control-probe race. #18504 and #18503 repair those separate boundaries. Historical missing-task absence remains quarantined; physical termination does not manufacture absence evidence. | | [41266385118](https://pages.paperclip.ing/evals-qa-smoke-41266385118-1/) | On `5c0830f` and App `756599e`, 13 passed, including the exact composed document helper. Hiring created the correctly assigned task, but its child admission failed before runtime selection because the organizational project had no repository source. #18514 repairs that selected-task boundary. The cancelled native lead retained an active prepared-workspace lease; cleanup 41271035231 failed with `owned_work_stop_unconfirmed`. #18517 addresses its file-preservation admission. Launch 1110, original evidence and automatic Slack FAIL remain failed. | | [CI 41257779002](https://github.com/paperclipai/paperclip/actions/runs/41257779002) / [CI 41263476916](https://github.com/paperclipai/paperclip/actions/runs/41263476916) | Existing enrollment and snapshot-retention fixtures observed intermediate I/O before worker settlement. #18506 and #18507 have controlled causal failures, complete-settlement observers and preserved deadlines/negative controls. Runtime scheduling behavior is unchanged. | **Fresh measured acceptance:** [QA run 41285131220](https://github.com/paperclipai/paperclip-cloud/actions/runs/41285131220) and its [permanent report](https://pages.paperclip.ing/evals-qa-smoke-41285131220-1/) passed **14/14**, zero failed or incomplete, with all five phases and complete cleanup for every case. The strict verifier independently matched original grades, current provenance and completed manifests: public harness `afa3f5552ab0c1a9f0ee7327dfa5665d57cc7bcb`, private workflow `8671fa93e27501fabbfffa377bec6dea453b5db7`, AppCore `050365e09d88411960fc3bacc1c08c46c0d9b6fa` and matching Cloud image/provider pack. All model, ROM, deadline and run-budget controls remained unchanged. All three workflow jobs completed successfully; the automatic Slack notification reported PASS with links to this run. The passing TestOps launch was closed normally after evidence verification. The hiring case passed all 11 check entries across nine IDs and five phases, with exactly five lead and two child native runs. Both saved documents match their live current revision/body hashes and exact hired-agent run attribution. The immutable original checklist remains intact; the same teammate authored the review. Both own callback replies contain their exact delegated task link. Independent canonical readback confirmed all seven native runs succeeded and committed their accepted result, assessment and decision, with no live owner and zero pending or failed effects; each of the seven exact owned leases has its own bound DESTROYED receipt and successful cleanup. All seven final snapshot operations match their source run, workspace, task, epoch, SHA, generation and export location. The four earlier lead snapshots are retained SUPERSEDED ancestors of the current committed snapshot; the latest lead and both child snapshots are COMMITTED. The initial diagnostic required current COMMITTED status for every historical snapshot and correctly remained false until the separate lineage diagnostic established this supported supersession. The product oracle and original grade were not changed. Canonical Suite metadata is `qa-basics`; the daily plan was saved with its standard suite-only query and synchronized, retaining all 14 ACTIVE cases. The older failed allocation remains separately stopped, retained, isolated from reuse and owned by terminated actors. That permits fresh campaigns without replaying old work. Its same-allocation files export is still unresolved and has separate bounded files-only recovery work; it is not claimed by this successful campaign. Original failed grades, reports and automatic FAIL notifications remain unchanged. No failed result was closed, relabelled, hidden or retried unchanged to obtain green. ## Risks The journey requires Agent Chat, normal hiring permission, a scoped lead and a provider that finalizes owned resources. Missing or extra work, wrong ownership, absent saved documents, missing callbacks and unconfirmed cleanup must fail. The deployed app and bundled runner pack must match the verified Core. The old run's cancellation ACK and terminal database row do not prove physical termination or saved files. Its coordinator has no accepted result or decision; pending-effect absence cannot be inferred from that missing decision. File recovery must preserve the exact allocation and original destroy disposition without a provider turn. Current source checks, reviews, fresh H7 and full-14 aggregate all pass. Canonical Suite metadata and actual 14-case daily-plan synchronization were verified after closing the genuine passing launch. The original failed allocation still has a separate files-export debt; its STOPPED receipt is not a SAVED or DESTROYED claim. ## Model Used OpenAI Codex, GPT-6 family. The session does not expose a more specific backend model ID or fixed context window. Used reasoning, repository tools, TypeScript and browser test execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge - [x] Historical passes and material failed attempts retain their actual source and outcome - [x] The composed document helper passed normal QA with complete cleanup on its actual source - [x] The corrected hiring journey passes fresh normal H7 with complete cleanup - [x] The active hiring case has canonical Suite and daily-plan membership after the passing launch is closed --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
271 lines
21 KiB
TypeScript
271 lines
21 KiB
TypeScript
import { mkdir, writeFile } from "node:fs/promises";
|
|
import { isDeepStrictEqual } from "node:util";
|
|
import path from "node:path";
|
|
import { expect, type Page } from "@playwright/test";
|
|
import { buildAgentMentionHref, buildProjectMentionHref, parseAgentMentionHref, parseProjectMentionHref } from "../../../packages/shared/src/project-mentions.js";
|
|
import { sendChatMessage, readChatOutputDocument, type ChatIssue, type ChatRun } from "../chat-flow.js";
|
|
import { assertChatHire, mutableIssueSnapshot } from "../chat-hardening.js";
|
|
import { ConnectionFailure } from "../connection-config.js";
|
|
import { assertHireAuditCoverage, assertHireBinding, assertHireDocument, assertHireRuns, assertHireTaskCreation, hireDelegateNames, hireResultLinkMatches, hireRunIssueId, observedHireResources, readHireConnectionChoice, type HireScope } from "./hire-delegate-evidence.js";
|
|
import { JourneyError, type JourneyContext, type JourneyEvidence, type TargetSession } from "./types.js";
|
|
import { sendHireHandoffMessage } from "./hire-delegate-composer.js";
|
|
|
|
type Row = Record<string, any>;
|
|
type Agent = Parameters<typeof assertChatHire>[0]["agents"][number] & Row;
|
|
type Task = ChatIssue & Row;
|
|
type Run = ChatRun & Row;
|
|
interface Options { deadlineAt: number; directory: string; capture(id: string, label: string, file: string): Promise<void> }
|
|
class HireReadTransportError extends Error {}
|
|
|
|
export function hireHandoffMentions(scope: Pick<HireScope, "companyId" | "leadId" | "projectId" | "campaignId">, hiredId: string, hired: Row, project: Row) {
|
|
if (![scope?.companyId, scope?.leadId, scope?.projectId, scope?.campaignId].every(value => typeof value === "string" && value.length > 0)) {
|
|
throw new JourneyError("failed", "hire_handoff_context_unverified");
|
|
}
|
|
const names = hireDelegateNames(scope.campaignId);
|
|
if (!hired || !project || typeof hiredId !== "string" || !hiredId || hired.id !== hiredId || hired.id === scope.leadId
|
|
|| hired.companyId !== scope.companyId || hired.reportsTo !== scope.leadId || hired.name !== names.hire
|
|
|| typeof project.id !== "string" || !project.id || project.id !== scope.projectId
|
|
|| project.companyId !== scope.companyId || project.name !== names.project) {
|
|
throw new JourneyError("failed", "hire_handoff_context_unverified");
|
|
}
|
|
const agentHref = buildAgentMentionHref(hiredId, hired.icon ?? null), projectHref = buildProjectMentionHref(project.id, project.color ?? null);
|
|
if (parseAgentMentionHref(agentHref)?.agentId !== hiredId || parseProjectMentionHref(projectHref)?.projectId !== project.id) {
|
|
throw new JourneyError("failed", "hire_handoff_context_unverified");
|
|
}
|
|
return Object.freeze({ teammate: `[@${hired.name}](${agentHref})`, project: `[@${project.name}](${projectHref})` });
|
|
}
|
|
|
|
export async function hireDelegateReuse(context: JourneyContext, session: TargetSession, campaignId: string, evidence: JourneyEvidence, options: Options) {
|
|
const { api, page } = context, agent = context.agent;
|
|
if (!agent || agent.runtimeMode !== "native" || !context.environmentId) throw new JourneyError("unsupported", "native_agent_and_environment_required");
|
|
const features = await api.get<Row>("/api/instance/settings/experimental");
|
|
if (features.enableAgentChat !== true) throw new JourneyError("unsupported", "agent_chat_required");
|
|
const company = await api.get<Row>(`/api/companies/${context.company.id}`);
|
|
if (company.requireBoardApprovalForNewAgents !== false) throw new JourneyError("unsupported", "automatic_hiring_policy_required");
|
|
const lead = await api.get<Row>(`/api/agents/${agent.id}`), names = hireDelegateNames(campaignId);
|
|
if (lead.id !== agent.id || lead.companyId !== context.company.id || lead.permissions?.canCreateAgents !== true || !lead.runtimeConfig?.aiConnection) {
|
|
throw new JourneyError("unsupported", "owned_hiring_agent_required");
|
|
}
|
|
const connectionChoice = await readHireConnectionChoice(api, { companyId: context.company.id,
|
|
responsibleUserId: context.user.id, binding: lead.runtimeConfig.aiConnection });
|
|
const check = (id: string, passed = true) => { evidence.checks.push({ id, passed }); if (!passed) throw new JourneyError("failed", id.replaceAll("-", "_")); };
|
|
await mkdir(options.directory, { recursive: true, mode: 0o700 });
|
|
const retain = (name: string, value: unknown) => writeFile(path.join(options.directory, name), JSON.stringify(value, null, 2), { mode: 0o600 });
|
|
await session.own({ kind: "project", name: names.project, journeyId: "hire-delegate-reuse" });
|
|
const project = await api.post<Row>(`/api/companies/${context.company.id}/projects`, { name: names.project, description: "Disposable native hiring and delegation verification." });
|
|
if (project.companyId !== context.company.id || !project.id || project.name !== names.project) throw new JourneyError("failed", "hire_project_binding_mismatch");
|
|
await session.own({ kind: "project", name: names.project, id: project.id, journeyId: "hire-delegate-reuse" });
|
|
const chatPath = `/api/companies/${context.company.id}/chats/${agent.id}`;
|
|
if (await api.get(chatPath)) throw new JourneyError("unsupported", "fresh_lead_chat_required");
|
|
const freshChat = { companyId: context.company.id, userId: context.user.id, leadAgentId: agent.id, absent: true as const };
|
|
await session.own({ kind: "issue", name: `Chat with ${lead.name}`, journeyId: "hire-delegate-reuse", freshChat });
|
|
const chatUrl = `${api.origin}/${context.company.issuePrefix}/chats/${agent.id}`;
|
|
await page.goto(chatUrl, { waitUntil: "domcontentloaded" });
|
|
await expect(page.getByTestId("task-chat-composer-input")).toBeVisible();
|
|
let scope: HireScope | undefined, hiredId: string | undefined;
|
|
let hiredConnectionBinding: unknown;
|
|
const known = new Set<string>();
|
|
const taskNames = [names.checklist, names.review];
|
|
const readObserved = async <T>(route: string): Promise<T> => {
|
|
try { return await api.get<T>(route); }
|
|
catch (error) {
|
|
if (error instanceof ConnectionFailure && (error.message === "api_request_failed"
|
|
|| /^api_status_(408|429|502|503|504)$/.test(error.message))) {
|
|
throw new HireReadTransportError("hire_observation_transport_unavailable");
|
|
}
|
|
throw error;
|
|
}
|
|
};
|
|
const readRuns = async (issueId: string) => {
|
|
const summaries = await readObserved<Row[]>(`/api/issues/${issueId}/runs`);
|
|
if (!Array.isArray(summaries) || summaries.length > 7) throw new JourneyError("failed", "hire_run_limit_mismatch");
|
|
return Promise.all(summaries.map(async row => {
|
|
if (typeof row.runId !== "string" || !row.runId) throw new JourneyError("failed", "hire_run_scope_mismatch");
|
|
const run = await readObserved<Run>(`/api/heartbeat-runs/${row.runId}`);
|
|
if (run.id !== row.runId) throw new JourneyError("failed", "hire_run_scope_mismatch");
|
|
return run;
|
|
}));
|
|
};
|
|
const observe = async () => {
|
|
const chat = await readObserved<Row | null>(chatPath);
|
|
if (!chat) return undefined;
|
|
if (chat.companyId !== context.company.id || chat.assigneeAgentId !== agent.id
|
|
|| chat.conversationAgentId !== agent.id || chat.conversationUserId !== context.user.id || chat.createdByUserId !== context.user.id) throw new JourneyError("failed", "hire_chat_scope_mismatch");
|
|
scope ??= { companyId: context.company.id, leadId: agent.id, chatId: chat.id, projectId: project.id, campaignId,
|
|
model: agent.model, provider: agent.provider, environmentId: context.environmentId!, connectionBinding: lead.runtimeConfig.aiConnection, connectionChoice,
|
|
origin: api.origin, issuePrefix: context.company.issuePrefix };
|
|
if (chat.id !== scope.chatId) throw new JourneyError("failed", "hire_chat_replaced");
|
|
if (!known.has(chat.id)) { await session.own({ kind: "issue", name: chat.title, id: chat.id, journeyId: "hire-delegate-reuse", freshChat }); known.add(chat.id); }
|
|
const leadProjection = await readRuns(chat.id);
|
|
const leadRuns = leadProjection.filter(run => run.companyId === scope!.companyId
|
|
&& run.agentId === scope!.leadId && hireRunIssueId(run) === chat.id);
|
|
const activity = await readObserved<Row[]>(`/api/companies/${context.company.id}/activity?agentId=${agent.id}&limit=500`);
|
|
assertHireAuditCoverage(activity, leadRuns);
|
|
const agents: Agent[] = [], tasks: Task[] = [], seen = new Set<string>();
|
|
let scopeMismatch = false;
|
|
for (const event of activity) {
|
|
if (event.companyId !== scope.companyId || event.actorType !== "agent" || event.actorId !== agent.id || event.agentId !== agent.id
|
|
|| !leadRuns.some(run => run.id === event.runId && run.companyId === scope!.companyId && run.agentId === agent.id
|
|
&& hireRunIssueId(run) === chat.id && run.runtimeMode === "native")) continue;
|
|
const kind = ["agent.hire_created", "agent.created"].includes(event.action) ? "agent"
|
|
: ["issue.created", "issue.child_created"].includes(event.action) ? "issue" : undefined;
|
|
if (!kind || event.entityType !== kind) continue;
|
|
if (typeof event.entityId !== "string" || !event.entityId) throw new JourneyError("failed", "hire_resource_scope_mismatch");
|
|
const key = `${kind}:${event.entityId}`;
|
|
if (seen.has(key)) continue;
|
|
if (seen.size >= 50) throw new JourneyError("failed", "hire_resource_coverage_exceeded");
|
|
seen.add(key);
|
|
const row = await readObserved<Row>(`/api/${kind === "agent" ? "agents" : "issues"}/${encodeURIComponent(event.entityId)}`);
|
|
if (row.id !== event.entityId) throw new JourneyError("failed", "hire_resource_scope_mismatch");
|
|
if (kind === "agent") agents.push(row as Agent); else tasks.push(row as Task);
|
|
const observed = observedHireResources(scope, { agents: kind === "agent" ? [row] : [], tasks: kind === "issue" ? [row] : [], activity, leadRuns });
|
|
scopeMismatch ||= observed.scopeMismatch;
|
|
for (const resource of [...observed.agents.map(row => ({ kind: "agent" as const, id: row.id, name: row.name })),
|
|
...observed.tasks.map(row => ({ kind: "issue" as const, id: row.id, name: row.title }))]) {
|
|
if (!known.has(resource.id)) { await session.own({ ...resource, journeyId: "hire-delegate-reuse" }); known.add(resource.id); }
|
|
}
|
|
}
|
|
const owned = observedHireResources(scope, { agents, tasks, activity, leadRuns });
|
|
if (scopeMismatch || owned.scopeMismatch) throw new JourneyError("failed", "hire_resource_scope_mismatch");
|
|
if (owned.agents.length) {
|
|
await readHireConnectionChoice({ get: readObserved }, { companyId: context.company.id, responsibleUserId: context.user.id,
|
|
binding: lead.runtimeConfig.aiConnection, hiredId: owned.agents[0]!.id });
|
|
const verified = assertHireBinding(scope, owned.agents, hiredId);
|
|
if (hiredConnectionBinding === undefined) hiredConnectionBinding = Object.freeze(structuredClone(verified.runtimeConfig.aiConnection));
|
|
else if (!isDeepStrictEqual(verified.runtimeConfig.aiConnection, hiredConnectionBinding)) throw new JourneyError("failed", "hire_native_binding_mismatch");
|
|
hiredId = verified.id;
|
|
}
|
|
assertHireTaskCreation(scope, hiredId, owned.tasks, activity, leadRuns);
|
|
const projectedRuns = [...leadProjection, ...(await Promise.all(owned.tasks.map(task => readRuns(task.id)))).flat()];
|
|
const canonical = new Map<string, Run>();
|
|
for (const run of projectedRuns) {
|
|
const previous = canonical.get(run.id);
|
|
if (previous && (previous.companyId !== run.companyId || previous.agentId !== run.agentId
|
|
|| hireRunIssueId(previous) !== hireRunIssueId(run))) throw new JourneyError("failed", "hire_run_scope_mismatch");
|
|
if (!previous) canonical.set(run.id, run);
|
|
}
|
|
const runs = [...canonical.values()];
|
|
const comments = await readObserved<Row[]>(`/api/issues/${chat.id}/comments?order=asc`);
|
|
const requestIds = comments.filter(row => row.authorUserId === context.user.id).map(row => row.id);
|
|
const completionRepliesReady = assertHireRuns(scope, hiredId, owned.tasks, runs, undefined, requestIds, comments);
|
|
for (const run of projectedRuns) assertHireRuns(scope, hiredId, owned.tasks, [run], undefined, requestIds, comments);
|
|
await retain("hire-delegate-observation.json", { scope: { ...scope, connectionBinding: undefined, connectionChoice: undefined }, chat, project,
|
|
agents: owned.agents.map(row => ({ id: row.id, name: row.name, reportsTo: row.reportsTo })), tasks: owned.tasks,
|
|
runs: runs.map(run => ({ id: run.id, issueId: hireRunIssueId(run), agentId: run.agentId, status: run.status })) });
|
|
return { chat, ...owned, runs, requestIds, comments, completionRepliesReady, allAgents: [lead as Agent, ...agents], activity };
|
|
};
|
|
const waitForIdle = async (count: number) => {
|
|
let awaitingReply = false;
|
|
while (Date.now() < options.deadlineAt) {
|
|
try {
|
|
const state = await observe();
|
|
awaitingReply = state?.completionRepliesReady === false;
|
|
if (state && state.completionRepliesReady && state.runs.length === count && state.runs.every(run => run.status === "succeeded")
|
|
&& state.chat.status === "in_review" && state.chat.conversationState === "waiting") {
|
|
assertHireRuns(scope!, hiredId, state.tasks, state.runs, count, state.requestIds, state.comments);
|
|
return state;
|
|
}
|
|
} catch (error) {
|
|
if (!(error instanceof HireReadTransportError)) throw error;
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
}
|
|
throw new JourneyError("failed", awaitingReply ? "hire_completion_reply_missing" : "hire_chat_not_completed");
|
|
};
|
|
const turn = async (text: string, count: number, handoff?: ReturnType<typeof hireHandoffMentions>) => {
|
|
await page.goto(chatUrl, { waitUntil: "domcontentloaded" });
|
|
const before = await api.get<Row | null>(chatPath);
|
|
const prior = before ? await api.get<Row[]>(`/api/issues/${before.id}/comments?order=asc`) : [];
|
|
if (handoff) await sendHireHandoffMessage(page, text, handoff, options.deadlineAt);
|
|
else await sendChatMessage(page, text);
|
|
const state = await waitForIdle(count);
|
|
const comments = await api.get<Row[]>(`/api/issues/${state.chat.id}/comments?order=asc`);
|
|
const added = comments.filter(row => row.authorUserId === context.user.id && !prior.some(old => old.id === row.id));
|
|
check("hire-user-messages-stored-once", added.length === 1 && added[0].body === text);
|
|
return state;
|
|
};
|
|
await session.own({ kind: "agent", name: names.hire, journeyId: "hire-delegate-reuse" });
|
|
let state = await turn(`Hire exactly one teammate named ${names.hire}, reporting to you, using your native runner, model, and available AI connection. Do not create or start any tasks yet. Tell me when this teammate is ready.`, 1);
|
|
const hired = assertHireBinding(scope!, state.agents);
|
|
check("one-attributed-native-hire", state.tasks.length === 0);
|
|
const handoff = hireHandoffMentions(scope!, hiredId!, hired, project);
|
|
const marker = `HIRE${campaignId.replace(/[^a-z0-9]/gi, "")}`;
|
|
await session.own({ kind: "issue", name: names.checklist, journeyId: "hire-delegate-reuse" });
|
|
state = await turn(`Have the existing ${handoff.teammate} write a concise launch checklist as a saved Paperclip document with the line "Reference: ${marker}". Create exactly one task titled ${names.checklist} in ${handoff.project}, already assigned to that teammate when it is created. Link the result here and let the teammate complete it. Do not hire another agent.`, 4, handoff);
|
|
const first = state.tasks.find(task => task.title === names.checklist);
|
|
check("first-delegated-task-complete", typeof first?.identifier === "string" && state.tasks.length === 1);
|
|
const readOutput = async (task: Row, token: string, runs: Row[]) => {
|
|
const document = await readChatOutputDocument(api, task.id, token);
|
|
const revisions = await api.get<Row[]>(`/api/issues/${task.id}/documents/${encodeURIComponent(document.key)}/revisions`);
|
|
assertHireDocument(scope!, task, document, revisions, runs, token, hired.id);
|
|
return document;
|
|
};
|
|
const firstOutput = await readOutput(first!, marker, state.runs);
|
|
const originalTask = mutableIssueSnapshot(await api.get<Task>(`/api/issues/${first!.id}`));
|
|
check("checklist-authored-by-hired-run");
|
|
await session.own({ kind: "issue", name: names.review, journeyId: "hire-delegate-reuse" });
|
|
state = await turn(`Have the existing ${handoff.teammate} review the checklist on ${first!.identifier} and write a separate saved review document with the line "Reference: REVIEW${marker}". Create exactly one task titled ${names.review} in ${handoff.project}, already assigned to that same teammate when it is created. Include the actual checklist in the handoff. Preserve the original checklist and task. Link the completed review here. Do not hire another agent.`, 7, handoff);
|
|
const second = state.tasks.find(task => task.title === names.review);
|
|
check("same-teammate-reused", typeof second?.identifier === "string" && state.tasks.length === 2 && state.tasks.every(task => taskNames.includes(task.title)));
|
|
const reviewOutput = await readOutput(second!, `REVIEW${marker}`, state.runs);
|
|
check("review-authored-by-same-hired-run");
|
|
check("original-checklist-preserved", isDeepStrictEqual(await api.get(`/api/issues/${first!.id}/documents/${encodeURIComponent(firstOutput.key)}`), firstOutput)
|
|
&& isDeepStrictEqual(mutableIssueSnapshot(await api.get<Task>(`/api/issues/${first!.id}`)), originalTask));
|
|
if (hiredConnectionBinding === undefined || !isDeepStrictEqual(
|
|
state.agents.find(row => row.id === hired.id)?.runtimeConfig?.aiConnection, hiredConnectionBinding)) {
|
|
throw new JourneyError("failed", "hire_native_binding_mismatch");
|
|
}
|
|
assertChatHire({ agents: state.allAgents, leadId: agent.id, hireName: names.hire, hiredId: hired.id,
|
|
connectionId: connectionChoice?.connectionId ?? lead.runtimeConfig.aiConnection.connectionId, binding: hiredConnectionBinding,
|
|
taskIds: [first!.id, second!.id], tasks: state.tasks, runs: state.runs });
|
|
assertHireRuns(scope!, hired.id, state.tasks, state.runs, 7, state.requestIds, state.comments);
|
|
const comments = await api.get<Row[]>(`/api/issues/${state.chat.id}/comments?order=asc`);
|
|
assertHireRuns(scope!, hired.id, state.tasks, state.runs, 7, state.requestIds, comments);
|
|
let resultsVisible = true;
|
|
for (const task of [first!, second!]) {
|
|
const callback = state.runs.find(run => {
|
|
const updates = run.contextSnapshot?.chatCompletionUpdates;
|
|
return run.contextSnapshot?.wakeReason === "chat_task_completed" && Array.isArray(updates) && updates[0]?.id === task.id;
|
|
});
|
|
if (!callback) throw new JourneyError("failed", "hire_completion_binding_mismatch");
|
|
await page.goto(chatUrl, { waitUntil: "domcontentloaded" }); await page.reload();
|
|
if (!await openSavedHireResult(page, api.origin, context.company.issuePrefix, comments, agent.id, task,
|
|
options.deadlineAt, { companyId: scope!.companyId, chatId: scope!.chatId, runId: callback.id })) {
|
|
resultsVisible = false; break;
|
|
}
|
|
}
|
|
check("delegated-result-visible-after-reload", resultsVisible);
|
|
check("configured-model-and-environment");
|
|
await retain("hire-delegate-completed.json", { hiredId: hired.id, firstTaskId: first!.id, secondTaskId: second!.id,
|
|
firstDocument: firstOutput, reviewDocument: reviewOutput, runIds: state.runs.map(run => run.id),
|
|
turns: state.runs.map(run => ({ id: run.id, issueId: hireRunIssueId(run), agentId: run.agentId,
|
|
wakeReason: run.contextSnapshot?.wakeReason, wakeCommentId: run.contextSnapshot?.wakeCommentId,
|
|
chatCompletionDeliveryIds: run.contextSnapshot?.chatCompletionDeliveryIds, chatCompletionUpdates: run.contextSnapshot?.chatCompletionUpdates })),
|
|
completionReplies: state.comments.filter(comment => state.runs.some(run => run.id === comment.createdByRunId
|
|
&& run.contextSnapshot?.wakeReason === "chat_task_completed")).map(comment => ({ id: comment.id, createdByRunId: comment.createdByRunId })) });
|
|
await options.capture("hire-delegate-reuse", "Hired teammate completed and reused", "hire-delegate-reuse.png");
|
|
}
|
|
|
|
export async function openSavedHireResult(page: Page, origin: string, issuePrefix: string, comments: Row[], leadId: string, task: Row, deadlineAt: number,
|
|
callback: { companyId: string; chatId: string; runId: string }) {
|
|
while (Date.now() < deadlineAt) {
|
|
for (const comment of comments.filter(row => row.authorAgentId === leadId && !row.authorUserId
|
|
&& row.companyId === callback.companyId && row.issueId === callback.chatId && row.createdByRunId === callback.runId)) {
|
|
const reply = page.locator(`[id=${JSON.stringify(`comment-${comment.id}`)}]`);
|
|
if (!await reply.isVisible()) continue;
|
|
const links = await reply.locator("a[href]").evaluateAll(elements => elements.map(element => element.getAttribute("href")!));
|
|
const href = links.find(href => hireResultLinkMatches({ origin, issuePrefix }, task, href));
|
|
if (!href) continue;
|
|
await reply.locator(`a[href=${JSON.stringify(href)}]`).first().click({ timeout: Math.max(1, deadlineAt - Date.now()) });
|
|
await page.reload();
|
|
await expect(page).toHaveURL(url => url.origin === origin
|
|
&& [task.id, task.identifier].some(id => url.pathname === `/${issuePrefix}/issues/${id}`), { timeout: Math.max(1, deadlineAt - Date.now()) });
|
|
await expect(page.getByTestId("issue-detail-header").getByRole("heading", { name: task.title, exact: true }))
|
|
.toBeVisible({ timeout: Math.max(1, deadlineAt - Date.now()) });
|
|
return true;
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, Math.min(200, Math.max(1, deadlineAt - Date.now()))));
|
|
}
|
|
return false;
|
|
}
|