Skip to content

Commit 76ca45c

Browse files
author
luigi liu
committed
Add internal check
1 parent b02142d commit 76ca45c

3 files changed

Lines changed: 179 additions & 75 deletions

File tree

‎awscli/customizations/agenttoolkit/state.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,14 +147,14 @@ def _is_valid_hook_run(run):
147147

148148

149149
def get_last_hook_run(path, state=None):
150-
"""Return the last run recorded for the hook at ``path``, or ``None``."""
150+
"""Return the last check recorded for ``path``, or ``None``."""
151151
return get_last_hook_runs(state).get(_hook_key(path))
152152

153153

154154
def record_hook_run(path, is_successful, summary, timestamp=None):
155-
"""Record the outcome of an automatic update run by the hook at ``path``.
155+
"""Record the outcome of an automatic update check of ``path``.
156156
157-
:param path: Harness config file the hook is installed in.
157+
:param path: Skills root the hook run checked.
158158
:param timestamp: Aware ``datetime`` of the run. Defaults to now.
159159
"""
160160
if timestamp is None:

‎awscli/customizations/agenttoolkit/update_skill.py‎

Lines changed: 74 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,12 @@
3030
HOOK_ERROR_MESSAGE,
3131
format_hook_message,
3232
format_hook_output,
33-
hook_config_path,
3433
)
3534
from awscli.customizations.agenttoolkit.state import (
3635
StateFileError,
3736
get_last_hook_run,
3837
is_auto_update_enabled,
38+
load_state,
3939
parse_timestamp,
4040
record_hook_run,
4141
toolkit_dir,
@@ -145,12 +145,41 @@ def summary(self):
145145
)
146146

147147
# Several sessions can start at once, and an agent may run more than one
148-
# hook at session start. Hook runs take turns, and a run that starts right
149-
# after another for the same agent finished does nothing, so the agent hears
150-
# about an update once.
148+
# hook at session start. Hook runs take turns, so a run that starts right
149+
# after another finds its skills roots already checked and does nothing.
151150
HOOK_LOCK_FILENAME = 'hook.lock'
152151
HOOK_LOCK_TIMEOUT = 30
153-
HOOK_RECENT_RUN_SECONDS = 10
152+
153+
# A hook run checks a skills root again once this long has passed since its
154+
# last check, or sooner if that check failed. Each root is tracked on its own,
155+
# so a check through one agent covers every agent sharing the root.
156+
HOOK_CHECK_INTERVAL = datetime.timedelta(hours=24)
157+
HOOK_RETRY_INTERVAL = datetime.timedelta(hours=1)
158+
159+
160+
def _is_check_due(last_run, now):
161+
if last_run is None:
162+
return True
163+
checked = parse_timestamp(last_run['timestamp'])
164+
if checked > now:
165+
# The clock moved back, so the record can't be trusted.
166+
return True
167+
if last_run['is_successful']:
168+
return now - checked >= HOOK_CHECK_INTERVAL
169+
return now - checked >= HOOK_RETRY_INTERVAL
170+
171+
172+
def _root_succeeded(root, results):
173+
"""Whether no skill failed to update or install in skills ``root``."""
174+
for name in results.failed_names:
175+
# Still installed here, so its update was due here.
176+
if os.path.exists(os.path.join(root, name, SKILL_METADATA_FILENAME)):
177+
return False
178+
for name in results.failed_install_names:
179+
# Still missing here, so the install was meant for here.
180+
if not os.path.lexists(os.path.join(root, name)):
181+
return False
182+
return True
154183

155184

156185
class UpdateSkillCommand(BasicCommand):
@@ -275,26 +304,40 @@ def _run_hook(self, agent_id, parsed_globals):
275304
return 0
276305
except OSError as e:
277306
LOG.debug('Could not lock the hook run: %s', e)
278-
if self._hook_ran_recently(agent_id):
279-
return 0
280307
return self._run_hook_locked(agent_id, parsed_globals)
281308

282-
def _hook_ran_recently(self, agent_id):
283-
run = get_last_hook_run(hook_config_path(self._hook_config(agent_id)))
284-
if run is None:
285-
return False
286-
age = datetime.datetime.now(datetime.timezone.utc) - parse_timestamp(
287-
run['timestamp']
288-
)
289-
return age.total_seconds() < HOOK_RECENT_RUN_SECONDS
309+
def _agents_due_for_check(self, agents):
310+
"""Return the agents with AWS skills whose skills root is due a check,
311+
and those roots."""
312+
now = datetime.datetime.now(datetime.timezone.utc)
313+
state = load_state()
314+
is_due_by_root = {}
315+
due_agents = []
316+
for agent in agents:
317+
if not agent.get_installed_skills():
318+
continue
319+
root = os.path.realpath(agent.skills_path)
320+
if root not in is_due_by_root:
321+
is_due_by_root[root] = _is_check_due(
322+
get_last_hook_run(root, state), now
323+
)
324+
if is_due_by_root[root]:
325+
due_agents.append(agent)
326+
due_roots = [root for root, is_due in is_due_by_root.items() if is_due]
327+
return due_agents, due_roots
290328

291329
def _run_hook_locked(self, agent_id, parsed_globals):
292330
# The agent shows or acts on whatever is printed, and a failing hook
293331
# is reported as an error, so nothing escapes and it always succeeds.
294332
results = _UpdateResults.empty()
295333
error = None
334+
due_roots = []
296335
try:
297-
agents = resolve_agents(None, self._agent_configs)
336+
agents, due_roots = self._agents_due_for_check(
337+
resolve_agents(None, self._agent_configs)
338+
)
339+
if not agents:
340+
return 0
298341
if self._collect_updates(
299342
agents, parsed_globals, None, results, HOOK_CLIENT_CONFIG
300343
):
@@ -314,11 +357,9 @@ def _run_hook_locked(self, agent_id, parsed_globals):
314357
results.failed_install_names,
315358
)
316359
if error is None:
317-
is_successful = not (results.failed or results.failed_installs)
318360
summary = results.summary()
319361
else:
320362
message = f'{message} {HOOK_ERROR_MESSAGE}'.lstrip()
321-
is_successful = False
322363
summary = f'Could not check for updates: {error}'
323364
self._stream.write(
324365
format_hook_output(
@@ -327,27 +368,22 @@ def _run_hook_locked(self, agent_id, parsed_globals):
327368
skills_updated=bool(results.updated or results.installed),
328369
)
329370
)
330-
self._record_hook_run(agent_id, is_successful, summary)
371+
self._record_hook_runs(due_roots, results, error, summary)
331372
return 0
332373

333-
def _hook_config(self, agent_id):
334-
# The hook's agent may not be detected, or not in a custom config
335-
# list, but every hook id has a config in AGENT_CONFIGS.
336-
return next(
337-
c
338-
for c in [*self._agent_configs, *AGENT_CONFIGS]
339-
if c.id == agent_id
340-
)
341-
342-
def _record_hook_run(self, agent_id, is_successful, summary):
343-
try:
344-
record_hook_run(
345-
hook_config_path(self._hook_config(agent_id)),
346-
is_successful=is_successful,
347-
summary=summary,
348-
)
349-
except StateFileError as e:
350-
LOG.debug('Could not record the hook run: %s', e)
374+
def _record_hook_runs(self, roots, results, error, summary):
375+
timestamp = datetime.datetime.now(datetime.timezone.utc)
376+
for root in roots:
377+
is_successful = error is None and _root_succeeded(root, results)
378+
try:
379+
record_hook_run(
380+
root,
381+
is_successful=is_successful,
382+
summary=summary,
383+
timestamp=timestamp,
384+
)
385+
except StateFileError as e:
386+
LOG.debug('Could not record the hook run: %s', e)
351387

352388
def _update_all_skills(self, agents, parsed_globals):
353389
results = _UpdateResults.empty()
@@ -404,9 +440,7 @@ def _collect_updates(
404440
results.failed.append((skill_name, e))
405441
continue
406442
if skill_skipped:
407-
results.skipped.extend(
408-
(skill_name, *s) for s in skill_skipped
409-
)
443+
results.skipped.extend((skill_name, *s) for s in skill_skipped)
410444
else:
411445
results.updated.append(skill_name)
412446
return True

‎tests/unit/customizations/agenttoolkit/test_update_skill.py‎

Lines changed: 102 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -959,13 +959,9 @@ def _run_hook(tmp_path, agent_id, client, configs=None, extra_args=()):
959959
return rc, stream.getvalue()
960960

961961

962-
def _last_run(tmp_path, agent_id='claude-code'):
963-
path = tmp_path / '.claude' / 'settings.json'
964-
if agent_id != 'claude-code':
965-
path = hooks.hook_config_path(
966-
next(c for c in AGENT_CONFIGS if c.id == agent_id)
967-
)
968-
return state.get_last_hook_run(str(path))
962+
def _last_run(tmp_path, agent_dir='.test-agent'):
963+
root = os.path.realpath(tmp_path / agent_dir / 'skills')
964+
return state.get_last_hook_run(root)
969965

970966

971967
def test_hook_does_nothing_when_auto_update_off(tmp_path):
@@ -1028,8 +1024,8 @@ def test_hook_output_per_agent(tmp_path, auto_update_on, agent_id, expected):
10281024
assert json.loads(output) == expected
10291025
else:
10301026
assert output == expected
1031-
# Recorded against the agent's hook file even though it isn't detected.
1032-
assert _last_run(tmp_path, agent_id)['is_successful'] is True
1027+
# Recorded against the skills root, whichever agent's hook ran.
1028+
assert _last_run(tmp_path)['is_successful'] is True
10331029

10341030

10351031
def test_hook_prints_nothing_when_up_to_date(tmp_path, auto_update_on):
@@ -1044,9 +1040,11 @@ def test_hook_prints_nothing_when_up_to_date(tmp_path, auto_update_on):
10441040
def test_hook_prints_nothing_without_skills_or_agents(
10451041
tmp_path, auto_update_on
10461042
):
1047-
rc, output = _run_hook(tmp_path, 'claude-code', _make_skill_client())
1043+
client = _make_skill_client()
1044+
rc, output = _run_hook(tmp_path, 'claude-code', client)
10481045
assert (rc, output) == (0, '')
1049-
assert _last_run(tmp_path)['is_successful'] is True
1046+
assert not client.get_latest_skill_version.called
1047+
assert state.get_last_hook_runs() == {}
10501048

10511049

10521050
def test_hook_reports_failures(tmp_path, auto_update_on):
@@ -1059,7 +1057,7 @@ def test_hook_reports_failures(tmp_path, auto_update_on):
10591057
'AWS agent skills updated: aws-cdk. Could not update aws-s3; run '
10601058
'"aws agent-toolkit update-skill --all" for details.\n'
10611059
)
1062-
run = _last_run(tmp_path, 'codex')
1060+
run = _last_run(tmp_path)
10631061
assert run['is_successful'] is False
10641062
assert run['summary'] == (
10651063
'1 updated, 0 skipped, 1 failed, 0 already up to date'
@@ -1213,10 +1211,13 @@ def test_hook_record_failure_is_ignored(tmp_path, auto_update_on):
12131211
rc, output = _run_hook(tmp_path, 'codex', _make_skill_client())
12141212
assert rc == 0
12151213
assert output == 'AWS agent skills updated: aws-s3.\n'
1216-
assert record.call_args.kwargs == {
1217-
'is_successful': True,
1218-
'summary': '1 updated, 0 skipped, 0 failed, 0 already up to date',
1219-
}
1214+
assert record.call_args.args == (
1215+
os.path.realpath(tmp_path / '.test-agent' / 'skills'),
1216+
)
1217+
assert record.call_args.kwargs['is_successful'] is True
1218+
assert record.call_args.kwargs['summary'] == (
1219+
'1 updated, 0 skipped, 0 failed, 0 already up to date'
1220+
)
12201221

12211222

12221223
def test_hook_accepts_all(tmp_path, auto_update_on):
@@ -1336,7 +1337,7 @@ def test_hook_reports_core_skill_install_failure(tmp_path, auto_update_on):
13361337
'--skill-name aws-iam" for details.\n'
13371338
)
13381339
assert not _skill_dir(tmp_path, 'aws-iam').exists()
1339-
run = _last_run(tmp_path, 'codex')
1340+
run = _last_run(tmp_path)
13401341
assert run['is_successful'] is False
13411342
assert run['summary'] == (
13421343
'0 updated, 0 skipped, 1 failed, 1 already up to date'
@@ -1399,35 +1400,104 @@ def test_hook_run_right_after_another_does_nothing(tmp_path, auto_update_on):
13991400
_install_skill_at_version(tmp_path, '.test-agent', 'aws-s3', 'v1')
14001401
_, output = _run_hook(tmp_path, 'kiro', _make_skill_client())
14011402
assert output == 'AWS agent skills updated: aws-s3.\n'
1402-
first = _last_run(tmp_path, 'kiro')
1403+
first = _last_run(tmp_path)
14031404
_install_skill_at_version(tmp_path, '.test-agent', 'aws-s3', 'v1')
14041405
client = _make_skill_client()
14051406
rc, output = _run_hook(tmp_path, 'kiro', client)
14061407
assert (rc, output) == (0, '')
14071408
assert not client.get_latest_skill_version.called
1408-
assert _last_run(tmp_path, 'kiro') == first
1409+
assert _last_run(tmp_path) == first
14091410

14101411

1411-
def test_hook_run_for_another_agent_is_not_skipped(tmp_path, auto_update_on):
1412+
def test_hook_run_for_another_agent_skips_checked_root(
1413+
tmp_path, auto_update_on
1414+
):
1415+
# The skills root was just checked through another agent's hook.
14121416
_install_skill_at_version(tmp_path, '.test-agent', 'aws-s3', 'v1')
14131417
_run_hook(tmp_path, 'kiro', _make_skill_client())
1414-
_install_skill_at_version(tmp_path, '.test-agent', 'aws-s3', 'v1')
1415-
_, output = _run_hook(tmp_path, 'codex', _make_skill_client())
1416-
assert output == 'AWS agent skills updated: aws-s3.\n'
1418+
client = _make_skill_client()
1419+
assert _run_hook(tmp_path, 'codex', client) == (0, '')
1420+
assert not client.get_latest_skill_version.called
14171421

14181422

1419-
def test_hook_runs_again_once_last_run_is_old(tmp_path, auto_update_on):
1420-
_install_skill_at_version(tmp_path, '.test-agent', 'aws-s3', 'v1')
1421-
config = next(c for c in AGENT_CONFIGS if c.id == 'kiro')
1423+
def _record_check(tmp_path, age, is_successful=True, agent_dir='.test-agent'):
14221424
state.record_hook_run(
1423-
hooks.hook_config_path(config),
1424-
is_successful=True,
1425+
os.path.realpath(tmp_path / agent_dir / 'skills'),
1426+
is_successful=is_successful,
14251427
summary='old',
1426-
timestamp=datetime.datetime.now(datetime.timezone.utc)
1427-
- datetime.timedelta(seconds=update_skill.HOOK_RECENT_RUN_SECONDS + 1),
1428+
timestamp=datetime.datetime.now(datetime.timezone.utc) - age,
14281429
)
1429-
_, output = _run_hook(tmp_path, 'kiro', _make_skill_client())
1430-
assert output == 'AWS agent skills updated: aws-s3.\n'
1430+
1431+
1432+
@pytest.mark.parametrize(
1433+
'age,is_successful,is_checked',
1434+
[
1435+
(update_skill.HOOK_CHECK_INTERVAL, True, True),
1436+
(
1437+
update_skill.HOOK_CHECK_INTERVAL - datetime.timedelta(minutes=1),
1438+
True,
1439+
False,
1440+
),
1441+
(update_skill.HOOK_RETRY_INTERVAL, False, True),
1442+
(
1443+
update_skill.HOOK_RETRY_INTERVAL - datetime.timedelta(minutes=1),
1444+
False,
1445+
False,
1446+
),
1447+
# A check recorded in the future means the clock moved back.
1448+
(-datetime.timedelta(days=7), True, True),
1449+
],
1450+
)
1451+
def test_hook_checks_root_once_interval_passed(
1452+
tmp_path, auto_update_on, age, is_successful, is_checked
1453+
):
1454+
_install_skill_at_version(tmp_path, '.test-agent', 'aws-s3', 'v1')
1455+
_record_check(tmp_path, age, is_successful)
1456+
client = _make_skill_client()
1457+
_, output = _run_hook(tmp_path, 'kiro', client)
1458+
assert client.get_latest_skill_version.called is is_checked
1459+
expected = 'AWS agent skills updated: aws-s3.\n' if is_checked else ''
1460+
assert output == expected
1461+
1462+
1463+
def test_hook_checks_only_roots_that_are_due(tmp_path, auto_update_on):
1464+
_install_skill_at_version(tmp_path, '.test-agent', 'aws-s3', 'v1')
1465+
_install_skill_at_version(tmp_path, '.claude', 'aws-cdk', 'v1')
1466+
_record_check(tmp_path, datetime.timedelta(minutes=1))
1467+
_, output = _run_hook(tmp_path, 'claude-code', _make_skill_client())
1468+
assert json.loads(output)['systemMessage'] == (
1469+
'AWS agent skills updated: aws-cdk.'
1470+
)
1471+
# The root that wasn't due keeps its record and its old skill.
1472+
assert _last_run(tmp_path)['summary'] == 'old'
1473+
marker = _skill_dir(tmp_path) / SKILL_METADATA_FILENAME
1474+
assert json.loads(marker.read_text()) == {'version': 'v1'}
1475+
assert _last_run(tmp_path, '.claude')['is_successful'] is True
1476+
1477+
1478+
def test_hook_records_failure_only_for_root_with_failed_skill(
1479+
tmp_path, auto_update_on
1480+
):
1481+
_install_skill_at_version(tmp_path, '.test-agent', 'aws-s3', 'v1')
1482+
_install_skill_at_version(tmp_path, '.claude', 'aws-cdk', 'v1')
1483+
client = _make_skill_client(bad_checksum_skill='aws-s3')
1484+
_run_hook(tmp_path, 'claude-code', client)
1485+
assert _last_run(tmp_path)['is_successful'] is False
1486+
assert _last_run(tmp_path, '.claude')['is_successful'] is True
1487+
1488+
1489+
def test_hook_records_failure_for_every_root_on_error(
1490+
tmp_path, auto_update_on
1491+
):
1492+
_install_skill_at_version(tmp_path, '.test-agent', 'aws-s3', 'v1')
1493+
_install_skill_at_version(tmp_path, '.claude', 'aws-cdk', 'v1')
1494+
client = _make_skill_client()
1495+
client.get_latest_skill_version.side_effect = EndpointConnectionError(
1496+
endpoint_url='https://example.com'
1497+
)
1498+
_run_hook(tmp_path, 'claude-code', client)
1499+
assert _last_run(tmp_path)['is_successful'] is False
1500+
assert _last_run(tmp_path, '.claude')['is_successful'] is False
14311501

14321502

14331503
def test_hook_does_nothing_while_another_run_holds_the_lock(

0 commit comments

Comments
 (0)